From 8dfb8c1cb3d17c67edc3a6c84222e5b09c43bb88 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 03:50:54 +0000 Subject: [PATCH 01/70] =?UTF-8?q?Feat:=20=E4=B8=A6=E5=88=97=E3=81=AE?= =?UTF-8?q?=E8=A8=88=E7=94=BB=E3=81=AE=E6=A4=9C=E6=9F=BB=E3=83=BB=E8=80=90?= =?UTF-8?q?=E4=B9=85=E3=83=AF=E3=83=BC=E3=82=AF=E3=83=95=E3=83=AD=E3=83=BC?= =?UTF-8?q?=E3=83=BB=E9=85=8D=E5=B8=83=E3=81=AE=E4=BE=9D=E9=A0=BC=E3=81=A8?= =?UTF-8?q?=E3=80=81=E7=B5=B1=E6=8B=AC=E5=BD=B9=E3=81=AE=E5=BD=B9=E5=89=B2?= =?UTF-8?q?=E3=81=AE=E6=95=B0=E3=81=88=E6=96=B9=E3=82=92=E8=B6=B3=E3=81=99?= =?UTF-8?q?=EF=BC=88#2436=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/durable.py | 88 +++++ plugins/ndf/scripts/lib/parallel_lane.py | 155 ++++++++ plugins/ndf/scripts/lib/parallel_plan.py | 342 ++++++++++++++++ plugins/ndf/scripts/lib/parallel_release.py | 153 ++++++++ plugins/ndf/scripts/lib/parallel_run.py | 369 ++++++++++++++++++ plugins/ndf/scripts/parallel.py | 365 +++++++++++++++++ plugins/ndf/scripts/relay_lib/__init__.py | 8 +- plugins/ndf/scripts/relay_lib/hub.py | 60 ++- plugins/ndf/scripts/relay_lib/hub_client.py | 65 ++- plugins/ndf/scripts/supervise.py | 19 +- plugins/ndf/scripts/supervise_lib/paths.py | 1 + .../ndf/scripts/supervise_lib/queue_fill.py | 26 +- .../supervise_lib/release_templates.py | 33 ++ plugins/ndf/scripts/supervise_lib/sprint.py | 27 +- .../ndf/scripts/supervise_lib/sprint_waves.py | 87 ++++- plugins/ndf/scripts/tests/test_relay_hub.py | 2 +- 16 files changed, 1765 insertions(+), 35 deletions(-) create mode 100644 plugins/ndf/scripts/lib/parallel_lane.py create mode 100644 plugins/ndf/scripts/lib/parallel_plan.py create mode 100644 plugins/ndf/scripts/lib/parallel_release.py create mode 100644 plugins/ndf/scripts/lib/parallel_run.py create mode 100644 plugins/ndf/scripts/parallel.py diff --git a/plugins/ndf/scripts/lib/durable.py b/plugins/ndf/scripts/lib/durable.py index f8ed76efe..569e42bf6 100644 --- a/plugins/ndf/scripts/lib/durable.py +++ b/plugins/ndf/scripts/lib/durable.py @@ -366,6 +366,94 @@ def resume_paused(ref: WorkflowRef, event: str = "pause") -> int: return seq +def recv(topic: str, timeout: float) -> Any: + """耐久ワークフローの中で、話題 `topic` のメッセージを `timeout` 秒まで待つ(無ければ None)。""" + return DBOS.recv(topic, timeout_seconds=timeout) + + +def set_event(key: str, value: Any) -> None: + """耐久ワークフローの中で、イベント `key` を `value` にする。""" + DBOS.set_event(key, value) + + +# ---- 外のプロセスから送る・読む(DBOS を起動せず、DBOSClient で記録のファイルを開く。#2436) ---- + + +@contextlib.contextmanager +def client(kind: str, identity: str) -> Any: + """`record_path(kind, identity)` の SQLite を開いた `DBOSClient`。ファイルが無ければ `DurableError`。""" + path = record_path(kind, identity) + if not path.exists(): + raise DurableError(f"耐久の記録 {path} が無い") + c = DBOSClient(system_database_url=_url(path)) + try: + yield c + finally: + c.destroy() + + +def send_to(kind: str, identity: str, wid: str, msg: Any, topic: str, key: str) -> None: + """外から耐久ワークフロー `wid` へメッセージを送る。同じ `key` の 2 度目は数えない。""" + with client(kind, identity) as c: + c.send(wid, msg, topic, idempotency_key=key) + + +def event_of(kind: str, identity: str, wid: str, key: str, timeout: float = 0) -> Any: + """外から耐久ワークフロー `wid` のイベント `key` を読む。`timeout` 秒待ち、無ければ None。""" + with client(kind, identity) as c: + return c.get_event(wid, key, timeout_seconds=timeout) + + +def status_of(kind: str, identity: str, wid: str) -> Optional[str]: + """外から耐久ワークフロー `wid` の状態を読む(記録が無い・耐久ワークフローが無いときは None)。""" + if not record_path(kind, identity).exists(): + return None + with client(kind, identity) as c: + rows = c.list_workflows(workflow_ids=[wid], load_input=False, load_output=False) + return rows[0].status if rows else None + + +def output_from(kind: str, identity: str, wid: str) -> Any: + """外から、終わった耐久ワークフロー `wid` の出力を読む(無ければ None)。""" + with client(kind, identity) as c: + rows = c.list_workflows(workflow_ids=[wid], load_input=False, load_output=True) + return rows[0].output if rows else None + + +def wait_from( + kind: str, + identity: str, + wid: str, + keys: Iterable[str], + after: Mapping[str, int] | int = 0, + timeout: Optional[float] = None, + poll: float = 1.0, +) -> Outcome: + """`wait` と同じ判定を外から行う。終わりか、`keys` のどれかのイベントの `seq` が `after` を超えるまで待つ。 + + `Outcome("event", {"key": <イベント名>, **値})`・`Outcome("done", 出力)`・`Outcome("timeout")` などを返す。""" + names = list(keys) + marks = {k: (after.get(k, 0) if isinstance(after, Mapping) else after) for k in names} + deadline = None if timeout is None else time.monotonic() + timeout + while True: + with client(kind, identity) as c: + rows = c.list_workflows(workflow_ids=[wid], load_input=False, load_output=True) + if not rows: + return Outcome("error", f"耐久ワークフロー {wid} が無い") + st = rows[0].status + if st == "SUCCESS": + return Outcome("done", rows[0].output) + if st in FINISHED: + return Outcome("cancelled" if st == "CANCELLED" else "error", st) + for k in names: + ev = c.get_event(wid, k, timeout_seconds=0) + if isinstance(ev, dict) and int(ev.get("seq", 0)) > marks[k]: + return Outcome("event", {"key": k, **ev}) + if deadline is not None and time.monotonic() >= deadline: + return Outcome("timeout") + time.sleep(poll) + + def wait(wid: str, key: Optional[str] = None, after: int = 0, poll: float = POLL_SECONDS, timeout: Optional[float] = None) -> Outcome: """耐久ワークフローの終わりか、イベント `key` の `seq` が `after` を超えるまで待つ。`timeout` 秒を過ぎたら `Outcome("timeout")`。""" deadline = None if timeout is None else time.monotonic() + timeout diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py new file mode 100644 index 000000000..129fb11ce --- /dev/null +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -0,0 +1,155 @@ +"""並列の計画のレーンの側(#2436): 自分が属する計画の判定(I12)・すでに配布済みかの判定(I15)・配布の依頼を送る・ +答えを読む(待たずに読むのと待つのの両方)・プランの本数を返す。 + +耐久の記録は外のプロセスとして `durable.py` の `send_to`・`event_of`・`status_of` で開く(DBOS を起動しない)。 +DBOS を読まずに済む判定(計画の有無・本数)は、耐久の記録のファイルが無ければ DBOS を import しない。 +""" + +from __future__ import annotations + +import json +import os +import secrets +import subprocess +import time +from pathlib import Path +from typing import Optional + +import parallel_plan as pp + +KIND = "parallel" +RUNNING = ("PENDING", "ENQUEUED") + + +def workflow_id(name: str) -> str: + """耐久ワークフローの ID(計画名から決まる)。""" + return f"parallel-{name}" + + +def identity(main: Path, name: str) -> str: + """耐久の記録の識別(計画の置き場の絶対パス)。""" + return str(pp.plan_dir(main, name).resolve()) + + +def record_exists(main: Path, name: str) -> bool: + from durable_keys import record_path + + return record_path(KIND, identity(main, name)).exists() + + +def run_status(main: Path, name: str) -> Optional[str]: + """計画の耐久ワークフローの状態(記録が無ければ None)。""" + if not record_exists(main, name): + return None + import durable + + return durable.status_of(KIND, identity(main, name), workflow_id(name)) + + +def plans_in(main: Path) -> list[str]: + root = Path(main) / pp.PARALLEL_DIR + return sorted(p.name for p in root.iterdir() if (p / "plan.json").is_file()) if root.is_dir() else [] + + +def lane_of(main: Path, lane: Optional[str] = None, status=run_status) -> Optional[tuple[str, pp.Lane]]: + """`NDF_RELAY_NAME` の名前が、耐久ワークフローが終わっていない計画のレーンにあれば (計画名, レーン)。無ければ None(I12)。""" + lane = lane if lane is not None else os.environ.get("NDF_RELAY_NAME") + if not lane: + return None + for name in plans_in(main): + try: + plan = pp.ParallelPlan.model_validate(pp.load(main, name), context={"max_plans": 10**6, "max_named": 10**6}) + except (OSError, ValueError): + continue + hit = plan.lane(lane) + if hit is not None and status(main, name) in RUNNING: + return name, hit + return None + + +def lane_plans(main: Path, lane: Optional[str] = None) -> Optional[int]: + """レーンなら、そのレーンの同時に流すプランの本数。レーンでなければ None(`queue --max` の既定)。""" + got = lane_of(main, lane) + return got[1].plans if got else None + + +def merged_on_main(root: Path, prs: list[int], run=subprocess.run) -> bool: + """`prs` のマージのコミットがすべて `origin/main` にあるか(I15)。1 本でも分からなければ False。""" + if not prs: + return False + for n in prs: + got = run(["gh", "pr", "view", str(n), "--json", "mergeCommit", "-q", ".mergeCommit.oid"], cwd=root, capture_output=True, text=True) + sha = (got.stdout or "").strip() + if got.returncode != 0 or not sha: + return False + anc = run(["git", "merge-base", "--is-ancestor", sha, "origin/main"], cwd=root, capture_output=True, text=True) + if anc.returncode != 0: + return False + return True + + +def released_version(main: Path, name: str, prs: list[int]) -> Optional[str]: + """`prs` を含む範囲のイベント `range-<連番>` の版(無ければ None)。""" + import durable + + ident, wid = identity(main, name), workflow_id(name) + n = 1 + found = None + while True: + ev = durable.event_of(KIND, ident, wid, f"range-{n}", 0) + if not isinstance(ev, dict): + return found + if set(prs) & set(ev.get("prs") or []): + found = ev.get("version") + n += 1 + + +def request_id(state: Path, lane: str) -> str: + """依頼の ID。最初の実行で `<レーン名>-r<乱数 12 桁>` を作って `work/request-id` へ書き、打ち直しでは読む。""" + p = Path(state) / "work" / "request-id" + if p.is_file() and p.read_text(encoding="utf-8").strip(): + return p.read_text(encoding="utf-8").strip() + p.parent.mkdir(parents=True, exist_ok=True) + rid = f"{lane}-r{secrets.randbelow(10**12):012d}" + p.write_text(rid + "\n", encoding="utf-8") + return rid + + +def send_request(main: Path, name: str, lane: str, prs: list[int], issues: list[int], state: Path) -> str: + """配布の依頼を話題 `inbox` へ送る。冪等の鍵は `:inbox:<依頼>`。依頼の ID を返す。""" + import durable + + rid = request_id(state, lane) + wid = workflow_id(name) + msg = { + "kind": "request", + "id": rid, + "lane": lane, + "prs": list(prs), + "issues": list(issues), + "sprint_state": str(state), + "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + durable.send_to(KIND, identity(main, name), wid, msg, "inbox", f"{wid}:inbox:{rid}") + return rid + + +def read_answer(main: Path, name: str, rid: str, timeout: float = 0) -> tuple[Optional[dict], Optional[dict]]: + """(答え `answer-<依頼>`, 知らせ `defer-<依頼>`)。`timeout` 秒まで答えを待つ(0 は待たない)。知らせでは返らない(I10)。""" + import durable + + ident, wid = identity(main, name), workflow_id(name) + deadline = time.monotonic() + max(0.0, timeout) + while True: + ans = durable.event_of(KIND, ident, wid, f"answer-{rid}", 0) + if isinstance(ans, dict): + return ans, None + if time.monotonic() >= deadline: + dfr = durable.event_of(KIND, ident, wid, f"defer-{rid}", 0) + return None, dfr if isinstance(dfr, dict) else None + time.sleep(min(5.0, max(0.2, deadline - time.monotonic()))) + + +def write_out(out: Path, data: dict) -> None: + Path(out).parent.mkdir(parents=True, exist_ok=True) + Path(out).write_text(json.dumps(data, ensure_ascii=False) + "\n", encoding="utf-8") diff --git a/plugins/ndf/scripts/lib/parallel_plan.py b/plugins/ndf/scripts/lib/parallel_plan.py new file mode 100644 index 000000000..c01a1fbdf --- /dev/null +++ b/plugins/ndf/scripts/lib/parallel_plan.py @@ -0,0 +1,342 @@ +"""並列の計画(#2436): `plan.json` の pydantic のモデル・検査(I1〜I4・I19)・`plan.md` の描画・引継ぎ文書の作成(I5)。 + +置き場はメインディレクトリの `.ndf/parallel/<計画名>/`(コミットしない)。検査の誤りは pydantic の検証の誤りとして +集め、誤りの種類(`PydanticCustomError` の type)の `I1`〜`I3`・`I19` を `items[].rule` に写す。上限の値は検証の +文脈(`context`)で渡す。触る場所の重なり(I4)は誤りにせず、検証の後に別に数える。 + +使う側は `deps.require("schema")` を先に呼ぶ。 +""" + +from __future__ import annotations + +import fnmatch +import graphlib +import os +import re +from pathlib import Path +from typing import Annotated, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, ValidationInfo, field_validator, model_validator +from pydantic_core import PydanticCustomError + +VERSION = 1 +PLAN_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,23}$") # 計画名は 24 字まで(一時レーンの名前を 32 字に収める。I1) +LANE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$") # 統括役の名前の形(relay_lib/common.py の NAME_RE と同じ) +HELPER_RE = re.compile(r"-rel\d+$") # 一時レーンの名前の形(`<計画名>-rel<連番>`) +DEFAULT_MAX_PLANS = 6 # 同時に流すプランの合計の既定(2026-09-25 の実測。8 コア・30 GB) +DEFAULT_MAX_NAMED = 3 # relay_lib/hub.py の max_named の既定 +PARALLEL_DIR = Path(".ndf") / "parallel" + + +def _positive_env(name: str, default: int) -> tuple[int, Optional[str]]: + """環境変数の正の整数。正の整数でなければ既定と、そのことを書いた 1 文を返す(I2)。""" + raw = os.environ.get(name) + if raw is None or raw == "": + return default, None + try: + v = int(raw) + except ValueError: + v = 0 + if v < 1: + return default, f"{name}={raw!r} は正の整数でないため既定の {default} を使った" + return v, None + + +def max_plans() -> tuple[int, Optional[str]]: + return _positive_env("NDF_PARALLEL_MAX_PLANS", DEFAULT_MAX_PLANS) + + +def max_named() -> tuple[int, Optional[str]]: + return _positive_env("NDF_RELAY_MAX_NAMED", DEFAULT_MAX_NAMED) + + +class LoopInput(BaseModel): + model_config = ConfigDict(extra="forbid") + kind: Literal["loop"] + loop: str = Field(min_length=1) + args: list[str] = Field(default_factory=list) + + +class SprintInput(BaseModel): + model_config = ConfigDict(extra="forbid") + kind: Literal["sprint"] + issues: list[int] = Field(min_length=1) + args: list[str] = Field(default_factory=list) + + +LaneInput = Annotated[Union[LoopInput, SprintInput], Field(discriminator="kind")] + + +class Lane(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str + input: LaneInput + plans: int = Field(ge=1) + touches: list[str] + after: list[str] = Field(default_factory=list) + handoff: Optional[str] = None + + @field_validator("name") + @classmethod + def _name(cls, v: str) -> str: + if not LANE_RE.match(v): + raise PydanticCustomError("I1", "レーン名の形が違う({pattern}): {name}", {"pattern": LANE_RE.pattern, "name": v}) + if HELPER_RE.search(v): + raise PydanticCustomError("I1", "レーン名が一時レーンの名前の形(-rel と数字で終わる)に当たる: {name}", {"name": v}) + return v + + def handoff_name(self) -> str: + """引継ぎ文書の名(ループなら `loop-<ループ名>`、スプリントなら `issue-<最初の番号>`)。""" + if self.handoff: + return self.handoff + if isinstance(self.input, LoopInput): + return f"loop-{self.input.loop}" + return f"issue-{self.input.issues[0]}" + + def start_command(self) -> str: + """レーンの開始のコマンド(設計の「レーンの最初の入力」の表)。""" + if isinstance(self.input, LoopInput): + args = " ".join(self.input.args) + mid = f" {args}" if args else "" + return ( + f"/goal /ndf:development-workflow --loop {self.input.loop}{mid}" + f" -- 終わり: [ndf-loop] stopped {self.input.loop} の行を出したとき" + ) + issues = " ".join(f"#{n}" for n in self.input.issues) + args = " ".join(self.input.args) + return f"/goal /ndf:development-workflow {issues}{(' ' + args) if args else ''}" + + def milestones(self) -> Optional[set[str]]: + """ループの `--milestones` の値(無ければ None。I19 の「ほかと重なる」)。""" + args = self.input.args + for i, a in enumerate(args): + if a.startswith("--milestones="): + return {m for m in a.split("=", 1)[1].split(",") if m} + if a == "--milestones" and i + 1 < len(args): + return {m for m in args[i + 1].split(",") if m} + return None + + +class ParallelPlan(BaseModel): + model_config = ConfigDict(extra="forbid") + version: Literal[1] + name: str + rules: list[str] + lanes: list[Lane] = Field(min_length=1) + + @field_validator("name") + @classmethod + def _name(cls, v: str) -> str: + if not PLAN_RE.match(v): + raise PydanticCustomError("I1", "計画名の形が違う({pattern}): {name}", {"pattern": PLAN_RE.pattern, "name": v}) + return v + + @model_validator(mode="after") + def _whole(self, info: ValidationInfo) -> "ParallelPlan": + ctx = info.context or {} + names = [ln.name for ln in self.lanes] + dup = sorted({n for n in names if names.count(n) > 1}) + if dup: + raise PydanticCustomError("I1", "レーン名が重なる: {names}", {"names": ", ".join(dup)}) + cap = int(ctx.get("max_named") or DEFAULT_MAX_NAMED) + if len(self.lanes) > cap: + raise PydanticCustomError("I2", "レーンの数 {n} が同時に動かせる数の上限 {cap} を超える", {"n": len(self.lanes), "cap": cap}) + total, limit = sum(ln.plans for ln in self.lanes), int(ctx.get("max_plans") or DEFAULT_MAX_PLANS) + if total > limit: + raise PydanticCustomError("I2", "プランの本数の合計 {n} が上限 {cap} を超える", {"n": total, "cap": limit}) + known = set(names) + for ln in self.lanes: + missing = [a for a in ln.after if a not in known] + if missing: + raise PydanticCustomError("I3", "{lane} の依存先が計画に無い: {names}", {"lane": ln.name, "names": ", ".join(missing)}) + try: + graphlib.TopologicalSorter({ln.name: set(ln.after) for ln in self.lanes}).prepare() + except graphlib.CycleError as exc: + cyc = " → ".join(exc.args[1]) if len(exc.args) > 1 else "" + raise PydanticCustomError("I3", "依存が循環する: {cycle}", {"cycle": cyc}) from None + overlap = _issue_overlap(self.lanes) + if overlap: + raise PydanticCustomError("I19", "レーンの間で課題かマイルストーンが重なる: {pairs}", {"pairs": overlap}) + return self + + def lane(self, name: str) -> Optional[Lane]: + return next((ln for ln in self.lanes if ln.name == name), None) + + +def _issue_overlap(lanes: list[Lane]) -> str: + """課題とマイルストーンの重なり(I19)。`--milestones` の無いループのレーンはほかのレーンと重なるものとする。""" + pairs = [] + for i, a in enumerate(lanes): + for b in lanes[i + 1 :]: + if _overlaps(a, b): + pairs.append(f"{a.name}・{b.name}") + return ", ".join(pairs) + + +def _overlaps(a: Lane, b: Lane) -> bool: + la, lb = isinstance(a.input, LoopInput), isinstance(b.input, LoopInput) + if not la and not lb: + return bool(set(a.input.issues) & set(b.input.issues)) + ma, mb = (a.milestones() if la else None), (b.milestones() if lb else None) + if (la and ma is None) or (lb and mb is None): + return True + if la and lb: + return bool(ma & mb) + return False # ループのマイルストーンとスプリントの課題の照合は課題の取得が要るため検査では見ない + + +def touch_overlaps(plan: ParallelPlan) -> list[dict]: + """触る場所が重なるレーンの組(I4。警告)。片方が他方の接頭辞か、glob が他方に当たれば重なる。""" + out = [] + for i, a in enumerate(plan.lanes): + for b in plan.lanes[i + 1 :]: + hits = sorted({f"{x}・{y}" for x in a.touches for y in b.touches if _touch_hit(x, y)}) + if hits: + out.append({"lanes": [a.name, b.name], "touches": hits}) + return out + + +def _touch_hit(x: str, y: str) -> bool: + px, py = x.rstrip("/"), y.rstrip("/") + if px == py or px.startswith(py + "/") or py.startswith(px + "/"): + return True + return fnmatch.fnmatch(px, py) or fnmatch.fnmatch(py, px) + + +# ---- 検査 ---- + + +def check_data(data: object) -> tuple[Optional[ParallelPlan], list[dict], dict]: + """下書きか `plan.json` の中身を検査する。(計画か None, items, metrics) を返す。items の `level` は error / warning。""" + plans_cap, plans_note = max_plans() + named_cap, named_note = max_named() + items: list[dict] = [] + notes = [n for n in (plans_note, named_note) if n] + try: + plan = ParallelPlan.model_validate(data, context={"max_plans": plans_cap, "max_named": named_cap}) + except ValidationError as exc: + plan = None + for e in exc.errors(): + rule = e["type"] if re.fullmatch(r"I\d+", e["type"]) else "form" + loc = ".".join(str(p) for p in e["loc"]) + items.append({"level": "error", "rule": rule, "at": loc or None, "message": e["msg"]}) + if plan is not None: + for ov in touch_overlaps(plan): + items.append({"level": "warning", "rule": "I4", "at": "lanes", "message": f"触る場所が重なる: {'・'.join(ov['lanes'])}({', '.join(ov['touches'])})", **ov}) + metrics = { + "errors": sum(1 for it in items if it["level"] == "error"), + "warnings": sum(1 for it in items if it["level"] == "warning"), + "max_plans": plans_cap, + "max_named": named_cap, + "notes": notes, + } + return plan, items, metrics + + +# ---- 置き場と描画 ---- + + +def plan_dir(main: Path, name: str) -> Path: + return Path(main) / PARALLEL_DIR / name + + +def load(main: Path, name: str) -> object: + """`plan.json` の中身(JSON の値)。無ければ FileNotFoundError、読めなければ ValueError。""" + import json + + return json.loads((plan_dir(main, name) / "plan.json").read_text(encoding="utf-8")) + + +def render_md(plan: ParallelPlan, progress: Optional[dict] = None) -> str: + """`plan.md`: レーンの表・決まり・(進行があれば)レーンの状態と配布の内訳。""" + lines = [f"# 並列の計画 {plan.name}", "", "`plan.json` から描画した。正本は `plan.json` である。", "", "## レーン", ""] + lines += ["| レーン | 最初の入力 | プランの本数 | 触る場所 | 依存先 | 引継ぎ文書 |", "| --- | --- | --- | --- | --- | --- |"] + for ln in plan.lanes: + first = f"ループ `{ln.input.loop}`" if isinstance(ln.input, LoopInput) else "スプリント " + " ".join(f"#{n}" for n in ln.input.issues) + if ln.input.args: + first += " `" + " ".join(ln.input.args) + "`" + lines.append( + f"| {ln.name} | {first} | {ln.plans} | {', '.join(f'`{t}`' for t in ln.touches) or '-'} | {', '.join(ln.after) or '-'} | `.ndf/handoff/{ln.handoff_name()}.md` |" + ) + lines += ["", "## レーンに共通の決まり", ""] + lines += [ + f"- 同時に流すプランの本数はレーンごとに上の表のとおり(合計 {sum(ln.plans for ln in plan.lanes)} 本)", + "- 触る場所は上の表のとおり。取り込みの衝突は base-sync で解く", + f"- 本番の配布はオーガナイザーが 1 本にまとめる。レーンは本番を自分で出さず、配布の依頼を送る(`parallel.py request {plan.name}`)", + "- 承認ゲートと区分つきの問いはオーガナイザーだけが出す。レーンは問いで止まらず、別の作業へ進む", + ] + lines += [f"- {r}" for r in plan.rules] + if progress: + lines += ["", "## 進行", "", "| レーン | 状態 | 起動 |", "| --- | --- | --- |"] + for n, st in sorted((progress.get("lanes") or {}).items()): + lines.append(f"| {n} | {st.get('state') or '-'} | {st.get('started') or st.get('refused') or '-'} |") + rels = progress.get("releases") or [] + if rels: + lines += ["", "## まとめた配布", "", "| 連番 | 状態 | 版 | コミット |", "| --- | --- | --- | --- |"] + for r in rels: + lines.append(f"| {r.get('seq')} | {r.get('state')} | {r.get('version') or '-'} | {(r.get('sha') or '-')[:12]} |") + return "\n".join(lines) + "\n" + + +def render_handoff(plan: ParallelPlan, lane: Lane, plan_md: Path) -> str: + """レーンの引継ぎ文書。決まりは写さず、並列の計画を参照する(受け入れ条件 2)。""" + cmd = lane.start_command() + return "\n".join( + [ + f"# 引継ぎ: 並列の計画 {plan.name} のレーン {lane.name}", + "", + "## 進め方(利用者の指示)", + "", + f"- 並列の計画 `{plan.name}` のレーン `{lane.name}` として進める。共通の決まりは [plan.md]({plan_md}) にあり、ここへは写さない", + "- 本番の配布は自分で出さず、オーガナイザーへ配布の依頼を送る。承認ゲートと区分つきの問いはオーガナイザーへ送り、待たずに別の作業へ進む", + "", + "## 次にやること", + "", + f"1. `{cmd}` で始める", + "", + "## 次に実行するコマンド", + "", + "```text", + cmd, + "```", + "", + ] + ) + + +def write_all(main: Path, plan: ParallelPlan, raw: dict) -> list[Path]: + """`plan.json`・`plan.md`・引継ぎ文書を書く。在る引継ぎ文書・在る計画があれば 1 つも書かずに FileExistsError(I5)。""" + import json + + d = plan_dir(main, plan.name) + handoffs = Path(main) / ".ndf" / "handoff" + targets = {ln.name: handoffs / f"{ln.handoff_name()}.md" for ln in plan.lanes} + existing = [str(p) for p in [d / "plan.json", *targets.values()] if p.exists()] + if existing: + raise FileExistsError(", ".join(existing)) + d.mkdir(parents=True, exist_ok=True) + handoffs.mkdir(parents=True, exist_ok=True) + data = dict(raw) + data["lanes"] = [{**dict(r), "handoff": ln.handoff_name()} for r, ln in zip(raw["lanes"], plan.lanes)] + written = [d / "plan.json", d / "plan.md"] + (d / "plan.json").write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + (d / "plan.md").write_text(render_md(plan), encoding="utf-8") + for ln in plan.lanes: + targets[ln.name].write_text(render_handoff(plan, ln, d / "plan.md"), encoding="utf-8") + written.append(targets[ln.name]) + return written + + +def manual_commands(plan: ParallelPlan) -> list[str]: + """ラッパーの外・tmux が無いときの、レーンごとの手で起動する 1 行(受け入れ条件 9)。""" + return [f"NDF_RELAY_NAME={ln.name} claude '{first_input(ln)}'" for ln in plan.lanes] + + +def first_input(lane: Lane) -> str: + """耐久ステップ「起動」がレーンへ渡す最初の入力。""" + return f"/goal /ndf:development-workflow .ndf/handoff/{lane.handoff_name()}.md の続きから" + + +def ready_lanes(plan: ParallelPlan, started: set[str], stopped: set[str]) -> list[Lane]: + """起動の対象: 起動に成功していないレーンのうち、依存先がすべて止まったもの(I7・I8)。""" + return [ln for ln in plan.lanes if ln.name not in started and all(a in stopped for a in ln.after)] diff --git a/plugins/ndf/scripts/lib/parallel_release.py b/plugins/ndf/scripts/lib/parallel_release.py new file mode 100644 index 000000000..80e503c27 --- /dev/null +++ b/plugins/ndf/scripts/lib/parallel_release.py @@ -0,0 +1,153 @@ +"""まとめた配布(#2436 の I9・I14・I15): 範囲の Pull Request をレーンへ対応づける(`present`)・提示したコミットと +`develop` の先頭を比べる 1 つの関数(`moved`)・範囲で答えられる依頼を決める(`answerable`)・提示のプランと本番の +プランを作る(`build_plans`)。 + +配布の中身は依頼の集合ではなく、提示したコミットの範囲 `main..` で決まる。内訳は承認する人が読むための表示である。 +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from typing import Callable, Iterable, Optional + +import parallel_plan as pp + +MAX_HELPER_SEQ = 9999 # 一時レーンの名前 `<計画名>-rel<連番>` を 32 字に収める(I1) + + +def moved(presented: str, head: str) -> bool: + """承認の答えの時点の `origin/develop` の先頭が、提示したコミットから進んでいるか(I14)。""" + return bool(head) and head.strip() != presented.strip() + + +def map_lanes(plan: pp.ParallelPlan, prs: list[int], closes: dict[int, list[int]], requests: Iterable[dict]) -> dict: + """範囲の Pull Request をレーンへ対応づける。 + + 依頼のあるレーンは依頼の `prs` が範囲にあるかで決め(依頼済みの印)、依頼の無いレーンは範囲の Pull Request が閉じる + 課題がスプリントのレーンの `input.issues` にあるかで決める。どちらにも当たらないものは `orphans` に並べる。""" + in_range = set(prs) + lanes: dict[str, dict] = {} + claimed: set[int] = set() + for req in requests: + hit = sorted(set(req.get("prs") or []) & in_range) + if hit: + row = lanes.setdefault(req["lane"], {"prs": [], "requested": True, "requests": []}) + row["requested"] = True + row["prs"] = sorted(set(row["prs"]) | set(hit)) + row["requests"].append(req["id"]) + claimed |= set(hit) + for ln in plan.lanes: + if not isinstance(ln.input, pp.SprintInput): + continue + own = sorted(n for n in prs if n not in claimed and set(closes.get(n) or []) & set(ln.input.issues)) + if own: + row = lanes.setdefault(ln.name, {"prs": [], "requested": False, "requests": []}) + row["prs"] = sorted(set(row["prs"]) | set(own)) + claimed |= set(own) + return {"lanes": lanes, "orphans": sorted(in_range - claimed)} + + +def answerable(pending: Iterable[dict], prs: Iterable[int]) -> list[str]: + """範囲 `prs` に Pull Request が入る依頼の ID(その配布の答えを受ける。I9・I15)。""" + got = set(prs) + return [r["id"] for r in pending if set(r.get("prs") or []) & got] + + +def breakdown_md(mapped: dict, added: Optional[dict] = None) -> str: + """承認資料へ足すレーンの内訳の節。出し直しなら加わった範囲を載せる。""" + lines = ["", "## レーンの内訳", "", "| レーン | 依頼 | Pull Request |", "| --- | --- | --- |"] + for name, row in sorted(mapped["lanes"].items()): + lines.append(f"| {name} | {'あり' if row['requested'] else 'なし'} | {', '.join(f'#{n}' for n in row['prs'])} |") + if mapped["orphans"]: + lines.append(f"| (レーンの無い Pull Request) | - | {', '.join(f'#{n}' for n in mapped['orphans'])} |") + if added: + lines += ["", "### 出し直しで加わった範囲", "", "| レーン | Pull Request |", "| --- | --- |"] + for name, row in sorted(added["lanes"].items()): + lines.append(f"| {name} | {', '.join(f'#{n}' for n in row['prs'])} |") + if added["orphans"]: + lines.append(f"| (レーンの無い Pull Request) | {', '.join(f'#{n}' for n in added['orphans'])} |") + return "\n".join(lines) + "\n" + + +def closing_issues(root: Path, prs: list[int], run: Callable = subprocess.run) -> dict[int, list[int]]: + """Pull Request が閉じる課題(`closingIssuesReferences`)。読めないものは空にする。""" + out: dict[int, list[int]] = {} + for n in prs: + got = run( + ["gh", "pr", "view", str(n), "--json", "closingIssuesReferences", "-q", "[.closingIssuesReferences[].number]"], + cwd=root, + capture_output=True, + text=True, + ) + try: + out[n] = [int(x) for x in json.loads(got.stdout or "[]")] if got.returncode == 0 else [] + except ValueError: + out[n] = [] + return out + + +def present( + root: Path, + plan: pp.ParallelPlan, + approval: Path, + requests: list[dict], + out_prs: Path, + presented_json: Path, + first: Optional[dict] = None, + closes_of: Callable = closing_issues, +) -> dict: + """承認資料から提示したコミットと含む Pull Request を読み、レーンへ対応づけて `presented.json` と `--out` を書く。 + + `first` は出し直しのときの提示 1 の `presented.json`(加わった範囲を載せる)。承認資料に承認したコミットが無ければ ValueError。""" + import approved_commit + from release_lib import included + + sha = approved_commit.material_sha(approval) + prs = included.read_included(approval) + closes = closes_of(root, prs) + mapped = map_lanes(plan, prs, closes, requests) + added = None + if first: + extra = [n for n in prs if n not in set(first.get("prs") or [])] + added = map_lanes(plan, extra, closes, requests) + data = {"sha": sha, "prs": prs, "lanes": mapped["lanes"], "orphans": mapped["orphans"], "added": added} + Path(presented_json).parent.mkdir(parents=True, exist_ok=True) + Path(presented_json).write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + Path(out_prs).parent.mkdir(parents=True, exist_ok=True) + Path(out_prs).write_text(" ".join(str(n) for n in prs) + "\n", encoding="utf-8") + with open(approval, "a", encoding="utf-8") as f: + f.write(breakdown_md(mapped, added)) + return data + + +def release_dir(main: Path, name: str, seq: int, k: int) -> Path: + return pp.plan_dir(main, name) / "releases" / str(seq) / str(k) + + +def helper_name(name: str, seq: int) -> str: + """一時レーンの名前(`<計画名>-rel<連番>`)。""" + return f"{name}-rel{seq}" + + +def pipeline_cmd(supervise: str, d: Path) -> str: + """一時レーンが流す 1 行(提示のプラン → 本番のプラン)。""" + return f"{supervise} queue {d / 'present.json'} --then {d / 'release.json'} --done {d / 'done.json'}" + + +def build_plans(main: Path, name: str, seq: int, k: int, version: str, builder: Optional[Callable] = None) -> Path: + """`releases/<連番>/<提示>/` に提示のプランと本番のプランと答えの記録(`{"gates": []}`)を書き、置き場を返す。 + + `builder(d, version)` が (提示のプラン, 本番のプラン) を返す。省けば `supervise_lib.parallel_plans` を使う。""" + d = release_dir(main, name, seq, k) + d.mkdir(parents=True, exist_ok=True) + if builder is None: + from supervise_lib.sprint_waves import plan_parallel_release as builder # type: ignore[no-redef] + present_plan, release_plan = builder(main, name, d, version) + (d / "present.json").write_text(json.dumps(present_plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + (d / "release.json").write_text(json.dumps(release_plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + answer = d / "answer.json" + if not answer.exists(): + answer.write_text(json.dumps({"gates": []}) + "\n", encoding="utf-8") + return d diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py new file mode 100644 index 000000000..08e6056e5 --- /dev/null +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -0,0 +1,369 @@ +"""並列の進行(#2436): 耐久ワークフロー `parallel-<計画名>` と耐久ステップ(起動・観測・停止・配布を組む・要約)。 + +耐久ワークフローの中の分岐は耐久ステップの結果とメッセージだけで決まる。時刻・外のコマンド・ファイルを読むのは +すべて耐久ステップである(打ち直しで同じ道を通るため。#1142 の I18)。外へ見せるのはイベントと出力だけである。 + +- メッセージ `inbox`: `{kind: request | gate1 | gate2 | ask | stop, ...}`(レーン・一時レーン・`stop`) +- メッセージ `resume`: `{n, phase: confirm, take}` / `{n, phase: settle, result, version, reason}`(`confirm`・`settle`) +- イベント `pause`: `{seq, action}`(`running` / `notice` / `release` / `confirmed`)、`questions`: `{seq, items}` +- イベント `answer-<依頼>`(最終の答え。1 回だけ)・`defer-<依頼>`(次の配布へ回した知らせ)・`range-<連番>`・`lane-<名>` + +統括役の CLI(`relay.py spawn`・`ls`・`stop`・`role`)は子プロセスで打ち、統一 JSON レポートだけを読む。 +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from pathlib import Path +from typing import Optional + +import durable +import parallel_lane as pl +import parallel_plan as pp +import parallel_release as pr + +HERE = Path(__file__).resolve().parent.parent +RELAY = HERE / "relay.py" +SUPERVISE = f"python3 {HERE / 'supervise.py'}" + + +def poll_seconds() -> float: + """`inbox` と `resume` を待つ秒(既定 60。テストは `NDF_PARALLEL_POLL` で縮める)。""" + try: + return max(0.1, float(os.environ.get("NDF_PARALLEL_POLL") or 60)) + except ValueError: + return 60.0 + + +def relay_argv() -> list[str]: + """統括役の CLI の打ち方。`NDF_PARALLEL_RELAY` があればその 1 行(テストの差し替え)。""" + alt = os.environ.get("NDF_PARALLEL_RELAY") + return alt.split() if alt else [sys.executable, str(RELAY)] + + +def relay(main: Path, *args: str) -> tuple[int, dict]: + """統括役の CLI を打ち、(終了コード, 統一 JSON レポート) を返す。読めなければ空の dict。""" + got = subprocess.run([*relay_argv(), *args], cwd=main, capture_output=True, text=True) + lines = [ln for ln in (got.stdout or "").splitlines() if ln.strip().startswith("{")] + try: + return got.returncode, json.loads(lines[-1]) if lines else {} + except ValueError: + return got.returncode, {} + + +def git_head(main: Path, ref: str = "origin/develop") -> str: + subprocess.run(["git", "fetch", "-q", "origin"], cwd=main, capture_output=True) + got = subprocess.run(["git", "rev-parse", ref], cwd=main, capture_output=True, text=True) + return (got.stdout or "").strip() + + +def dev_version(main: Path) -> str: + """`origin/develop` の `plugin.json` の開発版の版数から接尾辞を外した版(出す版)。""" + import re + + for rel in ("plugins/ndf/.claude-plugin/plugin.json",): + got = subprocess.run(["git", "show", f"origin/develop:{rel}"], cwd=main, capture_output=True, text=True) + if got.returncode == 0: + try: + return re.sub(r"-.*$", "", json.loads(got.stdout).get("version") or "") + except ValueError: + pass + return "" + + +# ---- 耐久ステップ ---- + + +@durable.step(name="parallel.init") +def step_init(main: str, name: str) -> dict: + """計画を読み、検査し、オーガナイザーの印を書く。""" + plan, items, _ = pp.check_data(pp.load(Path(main), name)) + if plan is None: + return {"ok": False, "items": items} + relay(Path(main), "role", "organizer", "--plan", name) + return {"ok": True, "lanes": [ln.model_dump(mode="json") for ln in plan.lanes]} + + +@durable.step(name="parallel.spawn") +def step_spawn(main: str, name: str, lanes: list[str]) -> dict: + """`lanes` を起動する。成功(同じ名前の 2 本目の断りも成功とする)は `started`、断りは `refused`(I7)。""" + plan = pp.ParallelPlan.model_validate(pp.load(Path(main), name), context={"max_plans": 10**6, "max_named": 10**6}) + out = {} + for n in lanes: + ln = plan.lane(n) + code, res = relay(Path(main), "spawn", "--name", n, "--json", "--", pp.first_input(ln)) + why = ((res.get("items") or [{}])[0] or {}).get("result") + if code == 0 or why == "name-taken": + out[n] = {"started": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())} + else: + out[n] = {"refused": why or res.get("summary") or f"exit {code}"} + return out + + +@durable.step(name="parallel.observe") +def step_observe(main: str) -> dict: + """`relay.py ls --json` のレーンの状態(名前 → running / awaiting_approval / stopped)。""" + _, res = relay(Path(main), "ls", "--json") + return {it["name"]: it.get("state") for it in res.get("items") or [] if isinstance(it, dict) and it.get("name")} + + +@durable.step(name="parallel.build") +def step_build(main: str, name: str, seq: int, k: int) -> dict: + """まとめた配布の提示のプランと本番のプランを書く。""" + if seq > pr.MAX_HELPER_SEQ: + return {"error": f"配布の連番 {seq} が一時レーンの名前の上限 {pr.MAX_HELPER_SEQ} を超える"} + version = dev_version(Path(main)) + d = pr.build_plans(Path(main), name, seq, k, version) + return {"dir": str(d), "version": version} + + +@durable.step(name="parallel.presented") +def step_presented(d: str) -> dict: + p = Path(d) / "presented.json" + return json.loads(p.read_text(encoding="utf-8")) if p.is_file() else {} + + +@durable.step(name="parallel.head") +def step_head(main: str) -> str: + return git_head(Path(main)) + + +@durable.step(name="parallel.stop-lanes") +def step_stop_lanes(main: str, names: list[str]) -> list[str]: + """各レーンへ `relay.py stop <名前>` を打ち、止まらなかった名前を返す。""" + return [n for n in names if relay(Path(main), "stop", n, "--json")[0] != 0] + + +@durable.step(name="parallel.finish") +def step_finish(main: str, name: str, progress: dict) -> None: + """役割の印を消し、`plan.md` を描画し直す。""" + relay(Path(main), "role", "--clear") + render(main, name, progress) + + +@durable.step(name="parallel.render") +def step_render(main: str, name: str, progress: dict) -> None: + render(main, name, progress) + + +def render(main: str, name: str, progress: dict) -> None: + try: + plan = pp.ParallelPlan.model_validate(pp.load(Path(main), name), context={"max_plans": 10**6, "max_named": 10**6}) + except (OSError, ValueError): + return + (pp.plan_dir(Path(main), name) / "plan.md").write_text(pp.render_md(plan, progress), encoding="utf-8") + + +# ---- 耐久ワークフロー ---- + + +class _Run: + """耐久ワークフローの中の値。打ち直すと記録から同じ値に戻る。""" + + def __init__(self, main: str, name: str, lanes: list[dict]): + self.main, self.name = main, name + self.order = [ln["name"] for ln in lanes] + self.after = {ln["name"]: list(ln.get("after") or []) for ln in lanes} + self.lanes: dict[str, dict] = {n: {} for n in self.order} + self.pending: list[dict] = [] + self.questions: list[dict] = [] + self.releases: list[dict] = [] + self.current: Optional[dict] = None + self.stop = False + self.seq = 0 + self.received: dict[str, int] = {} + + def bump(self) -> int: + self.seq += 1 + return self.seq + + def pause(self, action: str, **fields) -> None: + durable.set_event("pause", {"seq": self.bump(), "action": action, **fields}) + + def progress(self) -> dict: + return {"lanes": self.lanes, "releases": [{k: v for k, v in r.items() if k != "presented"} for r in self.releases]} + + def summary(self, status: str) -> dict: + return {"status": status, "lanes": self.lanes, "releases": self.progress()["releases"], "pending": [r["id"] for r in self.pending]} + + def stopped(self) -> set[str]: + return {n for n, st in self.lanes.items() if st.get("started") and st.get("state") == "stopped"} + + # -- 受け取り -- + + def take(self, msg) -> None: + if not isinstance(msg, dict): + return + kind = msg.get("kind") + if kind == "stop": + self.stop = True + elif kind == "request": + if all(r["id"] != msg.get("id") for r in self.pending): + self.pending.append(msg) + self.received[msg.get("lane", "")] = self.received.get(msg.get("lane", ""), 0) + 1 + durable.set_event(f"lane-{msg.get('lane')}", {"received": self.received[msg.get("lane", "")]}) + elif kind in ("gate1", "gate2", "ask"): + self.questions.append(msg) + durable.set_event("questions", {"seq": self.bump(), "items": self.questions}) + + def answer(self, rid: str, result: str, version: Optional[str], n: Optional[int], reason: Optional[str] = None) -> None: + durable.set_event(f"answer-{rid}", {"result": result, "version": version, "release": n, "reason": reason, "at": None}) + + # -- 配布 -- + + def answer_from_ranges(self) -> None: + """出した配布の範囲で答えられる依頼に答えて外す(I15)。""" + for rel in self.releases: + if rel.get("state") != "released": + continue + hit = set(pr.answerable(self.pending, rel.get("prs") or [])) + for r in [r for r in self.pending if r["id"] in hit]: + self.answer(r["id"], "released", rel.get("version"), rel["n"]) + self.pending = [r for r in self.pending if r["id"] not in hit] + + def open_release(self) -> None: + """流れている配布が無く依頼があれば、他のレーンを待たずに提示 1 を組む(I9)。""" + n = len(self.releases) + 1 + built = step_build(self.main, self.name, n, 1) + if built.get("error"): + self.stop = True + self.releases.append({"n": n, "state": "failed", "reason": built["error"]}) + return + rel = {"n": n, "state": "presented", "version": built["version"], "dirs": [built["dir"]], "presented": [], "requests": [r["id"] for r in self.pending]} + self.releases.append(rel) + self.current = rel + self.emit_release(rel, 1, represent=False) + + def emit_release(self, rel: dict, k: int, represent: bool) -> None: + d = Path(rel["dirs"][k - 1]) + self.pause( + "release", + n=rel["n"], + k=k, + represent=represent, + pipeline=pr.pipeline_cmd(SUPERVISE, d), + spawn=helper_spawn(self.name, rel["n"], k), + ) + + def resume(self, msg: dict) -> None: + rel = self.current + if rel is None or msg.get("n") != rel["n"]: + return + if msg.get("phase") == "confirm": + self.confirm(rel, msg.get("take")) + elif msg.get("phase") == "settle": + self.settle(rel, msg) + + def confirm(self, rel: dict, take: Optional[str]) -> None: + """承認の答えを受けたら 1 度だけ比べる。出し直しの提示への答えでは比べない(I14)。""" + if len(rel["dirs"]) == 1: + first = step_presented(rel["dirs"][0]) + head = step_head(self.main) + if pr.moved(first.get("sha") or "", head): + built = step_build(self.main, self.name, rel["n"], 2) + rel["dirs"].append(built["dir"]) + self.emit_release(rel, 2, represent=True) + return + k = 1 + else: + k = 2 if take == "new" else 1 + got = step_presented(rel["dirs"][k - 1]) + rel.update(state="confirmed", k=k, sha=got.get("sha"), prs=got.get("prs") or []) + self.pause("confirmed", n=rel["n"], k=k, plan=str(Path(rel["dirs"][k - 1]) / "release.json"), spawn=helper_spawn(self.name, rel["n"], k)) + + def settle(self, rel: dict, msg: dict) -> None: + result = msg.get("result") + if not rel.get("prs"): + got = step_presented(rel["dirs"][-1]) + rel["prs"] = got.get("prs") or [] + hit = set(pr.answerable(self.pending, rel["prs"])) + if result == "released": + rel.update(state="released", version=msg.get("version") or rel.get("version")) + durable.set_event(f"range-{rel['n']}", {"sha": rel.get("sha"), "version": rel["version"], "prs": rel["prs"], "lanes": sorted({r["lane"] for r in self.pending if r["id"] in hit})}) + for r in [r for r in self.pending if r["id"] in hit]: + self.answer(r["id"], "released", rel["version"], rel["n"]) + else: + rel.update(state="rejected" if result == "rejected" else "failed", reason=msg.get("reason")) + for r in [r for r in self.pending if r["id"] in hit]: + self.answer(r["id"], rel["state"], None, rel["n"], msg.get("reason")) + self.pending = [r for r in self.pending if r["id"] not in hit] + for r in self.pending: + durable.set_event(f"defer-{r['id']}", {"seq": rel["n"], "reason": "範囲に入らないため次の配布へ回した", "at": None}) + self.current = None + + +def helper_spawn(name: str, n: int, k: int) -> str: + """一時レーンを起こす 1 行(`metrics.spawn`)。""" + first = f"/goal /ndf:development-workflow --parallel-helper {name} {n} {k} -- 終わり: [ndf-parallel] helper-done {name} の行を出したとき" + return f"python3 {RELAY} spawn --name {pr.helper_name(name, n)} --role helper --plan {name} --json -- '{first}'" + + +@durable.workflow(name="parallel") +def parallel_workflow(main: str, name: str) -> dict: + init = step_init(main, name) + if not init.get("ok"): + return {"status": "stopped", "lanes": {}, "releases": [], "pending": [], "errors": init.get("items")} + run = _Run(main, name, init["lanes"]) + run.pause("running") + wait = poll_seconds() + while True: + if run.stop: + left = step_stop_lanes(main, [n for n, st in run.lanes.items() if st.get("started")]) + step_finish(main, name, run.progress()) + return {**run.summary("stopped"), "not_stopped": left} + ready = pp.ready_lanes(_plan_view(run), {n for n, st in run.lanes.items() if st.get("started")}, run.stopped()) + if ready: + for n, got in step_spawn(main, name, [ln.name for ln in ready]).items(): + run.lanes[n].update(got) + if run.current is None and run.pending: + run.answer_from_ranges() + if run.pending: + run.open_release() + if run.current is None and not run.pending and len(run.stopped()) == len(run.order): + step_finish(main, name, run.progress()) + return run.summary("finished") + run.take(durable.recv("inbox", wait if run.current is None else min(wait, 30))) + if run.current is not None: + msg = durable.recv("resume", min(wait, 30)) + if isinstance(msg, dict): + run.resume(msg) + states = step_observe(main) + noticed = [] + for n in run.order: + st = states.get(n) + if st is None and run.lanes[n].get("started"): + st = "stopped" + if st and st != run.lanes[n].get("state"): + run.lanes[n]["state"] = st + if st == "awaiting_approval": + noticed.append(n) + if noticed: + run.pause("notice", lanes=noticed, attach=[f"python3 {RELAY} attach {n}" for n in noticed]) + + +def _plan_view(run: _Run) -> pp.ParallelPlan: + """起動の判定に使う計画の形(耐久ワークフローの入力から作る。ファイルを読まない)。""" + lanes = [{"name": n, "input": {"kind": "loop", "loop": n, "args": ["--milestones", n]}, "plans": 1, "touches": [], "after": run.after[n]} for n in run.order] + return pp.ParallelPlan.model_construct(version=1, name=run.name, rules=[], lanes=[pp.Lane.model_construct(**ln) for ln in lanes]) + + +# ---- 外の入口 ---- + + +def open_record(main: Path, name: str, lock_timeout: Optional[float] = 0) -> None: + durable.launch(pl.KIND, pl.identity(main, name), keep=pl.workflow_id(name), lock_timeout=lock_timeout) + + +def run(main: Path, name: str) -> tuple[dict, bool]: + """耐久ワークフローを始めるか続け、終わるまで待つ。(出力, 再開か) を返す。""" + wid = pl.workflow_id(name) + open_record(main, name) + st = durable.status(wid) + resumed = st is not None + if st is None: + durable.start(durable.WorkflowRef(wid, 1, "start"), parallel_workflow, str(main), name) + return durable.output_of(wid), resumed diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py new file mode 100644 index 000000000..a93348871 --- /dev/null +++ b/plugins/ndf/scripts/parallel.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +"""並列の計画と進行の CLI(#2436)。結果は統一 JSON レポートを 1 行で標準出力へ出す。 + +| 副命令 | 役割 | +| --- | --- | +| `init <計画名>` | 標準入力の下書きを検査し、`plan.json`・`plan.md`・レーンごとの引継ぎ文書を書く(在る文書は上書きせず、何も書かない) | +| `check <計画名>` | 並列の計画を検査する(誤りは 1、警告だけなら 0) | +| `start <計画名>` | `run` をセッションから切り離して起動する(動いていれば今の pid を返す) | +| `run <計画名>` | 耐久ワークフローを始めるか続け、終わるまで動き続ける。ラッパーの外・tmux が無ければ手で起動する 1 行を返す(3) | +| `wait <計画名> [--after ] [--timeout 秒]` | 状態の変化(notice / release / confirmed / questions)か終わりまで待つ。上限なら 3 | +| `present <計画名> <連番> <提示> --approval <承認資料> --out <パス> [--first ]` | 範囲の Pull Request をレーンへ対応づける | +| `confirm <計画名> <連番> [--take new\\|first]` | 承認ゲート 2 の承認を送る | +| `settle <計画名> <連番> (--done \\| --rejected [--reason 文])` | 本番の配布の結果を送る | +| `stop <計画名>` | 計画の全レーンを止め、耐久ワークフローを終える | +| `request <計画名> --lane <名> --state <パス> --out <パス> [--prs ...] [--issues ...]` | 配布の依頼を送る(10)か、すでに `main` にあれば送らない(0) | +| `await <計画名> --from <パス> [--timeout 秒]` | 依頼の答えを読む(released 0・rejected 10・failed 1・まだ無い 3) | + +置き場はメインディレクトリの `.ndf/parallel/<計画名>/`(コミットしない)。正本の手順は +`skills/development-workflow/references/parallel.md` と `skills/issue-plan-strategy/references/parallel-plan.md` にある。 +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent / "lib")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import deps # noqa: E402 + +deps.require("durable", "schema") + +import decl # noqa: E402 +import parallel_lane as pl # noqa: E402 +import parallel_plan as pp # noqa: E402 +from step_result import emit, result # noqa: E402 + +TOOL = "parallel" +SELF = Path(__file__).resolve() + + +def main_of() -> Path: + cwd = os.getcwd() + return decl.main_dir(cwd) or Path(cwd) + + +def out(status: str, summary: str, items=None, code: int | None = None, **metrics): + emit(result(TOOL, status, summary, items, metrics), code) + + +def load_plan(main: Path, name: str) -> tuple[pp.ParallelPlan | None, list[dict], dict]: + try: + data = pp.load(main, name) + except FileNotFoundError: + out("stopped", f"並列の計画 {name} が無い(置き場: {pp.plan_dir(main, name)}/plan.json)", code=1) + except (OSError, ValueError) as exc: + out("stopped", f"並列の計画 {name} を読めない: {exc}", code=2) + return pp.check_data(data) + + +# ---- 計画 ---- + + +def cmd_init(a) -> None: + main = main_of() + try: + raw = json.loads(sys.stdin.read()) + except ValueError as exc: + out("stopped", f"下書きを読めない: {exc}", code=2) + if not isinstance(raw, dict): + out("stopped", "下書きが JSON のオブジェクトでない", code=2) + raw = {**raw, "name": a.name} + plan, items, metrics = pp.check_data(raw) + if plan is None: + out("stopped", f"並列の計画 {a.name} の検査に誤りがある({metrics['errors']} 件)。何も書いていない", items, 1, **metrics) + try: + written = pp.write_all(main, plan, raw) + except FileExistsError as exc: + out("stopped", f"在る文書を上書きしないため何も書いていない: {exc}", items, 1, existing=str(exc).split(", ")) + except OSError as exc: + out("stopped", f"書けない: {exc}", items, 2) + out("ok", f"並列の計画 {a.name} を書いた(レーン {len(plan.lanes)} 本)", [{"path": str(p)} for p in written] + items, **metrics) + + +def cmd_check(a) -> None: + plan, items, metrics = load_plan(main_of(), a.name) + if plan is None: + out("stopped", f"並列の計画 {a.name} の検査に誤りがある({metrics['errors']} 件)", items, 1, **metrics) + out("ok", f"並列の計画 {a.name} の検査を通った(警告 {metrics['warnings']} 件)", items, **metrics) + + +# ---- 進行 ---- + + +def _manual_or_none(main: Path, plan: pp.ParallelPlan) -> None: + """ラッパーの外・tmux が無いときは、レーンごとの手で起動する 1 行を返して 3 で終える(受け入れ条件 9)。""" + import parallel_run as pr_run + + why = None + if not os.environ.get("NDF_RELAY_DIR"): + why = "ラッパーの外(NDF_RELAY_DIR が無い)" + else: + _, res = pr_run.relay(main, "ls", "--json") + m = res.get("metrics") or {} + if not m.get("tmux"): + why = "tmux が無い" + elif not m.get("hub"): + why = "統括役が居ない" + if why: + lines = pp.manual_commands(plan) + out("stopped", f"{why}ため、レーンを起動しない。手で起動する 1 行を items に並べた", [{"lane": ln.name, "command": c} for ln, c in zip(plan.lanes, lines)], 3, manual=True) + + +def _wait_line(name: str) -> str: + return f"python3 {SELF} wait {name}" + + +def cmd_run(a) -> None: + main = main_of() + plan, items, metrics = load_plan(main, a.name) + if plan is None: + out("stopped", f"並列の計画 {a.name} の検査に誤りがあるため、レーンを 1 本も起動しない", items, 1, **metrics) + if pl.run_status(main, a.name) is None: + _manual_or_none(main, plan) + import durable + import parallel_run as pr_run + + try: + got, resumed = pr_run.run(main, a.name) + except durable.DurableError as exc: + if "動いている" in str(exc): + # 設計の終了コード 5 は統一 JSON レポートの終了コードの決まり(step_result.code_matches)に無いため、 + # 動いている run を続ける答え(ok・running)として返す + out("ok", f"同じ計画の run が動いている。wait で待つ: {exc}", running=True, next=_wait_line(a.name)) + out("stopped", f"耐久の記録を開けない: {exc}", code=2) + status = got.get("status") if isinstance(got, dict) else None + lanes = [{"lane": n, **st} for n, st in sorted((got or {}).get("lanes", {}).items())] + out("ok", f"並列の計画 {a.name} は {status} で終わった", lanes, resumed=resumed, action=status, releases=(got or {}).get("releases")) + + +def cmd_start(a) -> None: + main = main_of() + plan, items, metrics = load_plan(main, a.name) + if plan is None: + out("stopped", f"並列の計画 {a.name} の検査に誤りがあるため、レーンを 1 本も起動しない", items, 1, **metrics) + pidfile = pp.plan_dir(main, a.name) / "run.pid" + try: + pid = int(pidfile.read_text().strip()) + os.kill(pid, 0) + out("ok", f"run は動いている(pid {pid})", pid=pid, next=_wait_line(a.name)) + except (OSError, ValueError): + pass + if pl.run_status(main, a.name) is None: + _manual_or_none(main, plan) + log = pp.plan_dir(main, a.name) / "run.log" + with open(log, "ab") as f: + p = subprocess.Popen([sys.executable, str(SELF), "run", a.name], cwd=main, stdout=f, stderr=f, start_new_session=True) + pidfile.write_text(f"{p.pid}\n") + out("ok", f"run を切り離して起動した(pid {p.pid})", pid=p.pid, log=str(log), next=_wait_line(a.name)) + + +def cmd_wait(a) -> None: + import durable + + main = main_of() + if not pl.record_exists(main, a.name): + out("stopped", f"並列の計画 {a.name} の耐久の記録が無い", code=2) + got = durable.wait_from(pl.KIND, pl.identity(main, a.name), pl.workflow_id(a.name), ["pause", "questions"], a.after, a.timeout) + if got.kind == "timeout": + out("stopped", f"{a.timeout:.0f} 秒の上限に達した。同じ wait を打ち直す", code=3, seq=a.after, next=_wait_line(a.name) + f" --after {a.after}") + if got.kind == "event": + ev = dict(got.value) + key = ev.pop("key") + action = ev.get("action") if key == "pause" else "questions" + if action == "running": + out("stopped", "まだ変化が無い", code=3, seq=ev.get("seq")) + items = ev.pop("items", None) or [] + out("ok", f"並列の計画 {a.name}: {action}", items, action=action, **ev) + if got.kind == "done": + o = got.value or {} + lanes = [{"lane": n, **st} for n, st in sorted((o.get("lanes") or {}).items())] + out("ok", f"並列の計画 {a.name} は {o.get('status')} で終わった", lanes, action=o.get("status"), releases=o.get("releases")) + out("stopped", f"耐久ワークフローが {got.kind} で終わった: {got.value}", code=1, action=got.kind) + + +def cmd_present(a) -> None: + import parallel_release as pr + + main = main_of() + plan, items, metrics = load_plan(main, a.name) + if plan is None: + out("stopped", f"並列の計画 {a.name} の検査に誤りがある", items, 1) + d = pr.release_dir(main, a.name, a.n, a.k) + first = json.loads(Path(a.first).read_text(encoding="utf-8")) if a.first and Path(a.first).is_file() else None + reqs = _requests_for(main, a.name) + try: + got = pr.present(main, plan, Path(a.approval), reqs, Path(a.out), d / "presented.json", first) + except ValueError as exc: + out("stopped", f"承認資料に承認したコミットが無い: {exc}", code=1) + except OSError as exc: + out("stopped", f"読めない: {exc}", code=2) + Path(a.out).with_name("approved.txt").write_text(got["sha"] + "\n", encoding="utf-8") + out("ok", f"提示 {a.n}-{a.k}: {got['sha'][:12]} まで(Pull Request {len(got['prs'])} 本)", [{"lane": n, **r} for n, r in got["lanes"].items()], sha=got["sha"], orphans=got["orphans"]) + + +def _requests_for(main: Path, name: str) -> list[dict]: + """オーガナイザーが読める依頼(`releases/requests.json`。耐久ワークフローの外からは依頼の一覧を読めないため、無ければ空)。""" + p = pp.plan_dir(main, name) / "releases" / "requests.json" + try: + return json.loads(p.read_text(encoding="utf-8")) + except (OSError, ValueError): + return [] + + +def _resume(main: Path, name: str, msg: dict, key: str) -> None: + import durable + + durable.send_to(pl.KIND, pl.identity(main, name), pl.workflow_id(name), msg, "resume", key) + + +def cmd_confirm(a) -> None: + main = main_of() + if pl.run_status(main, a.name) not in pl.RUNNING: + out("stopped", f"並列の計画 {a.name} が動いていない", code=1) + wid = pl.workflow_id(a.name) + _resume(main, a.name, {"n": a.n, "phase": "confirm", "take": a.take}, f"{wid}:resume:confirm:{a.n}:{a.take or '-'}") + out("ok", f"配布 {a.n} の承認を送った", n=a.n, take=a.take) + + +def cmd_settle(a) -> None: + main = main_of() + if pl.run_status(main, a.name) not in pl.RUNNING: + out("stopped", f"並列の計画 {a.name} が動いていない", code=1) + if a.rejected: + res, version = "rejected", None + else: + try: + done = json.loads(Path(a.done).read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + out("stopped", f"done を読めない: {exc}", code=2) + results = [str(i.get("result")) for i in done.get("items") or [] if isinstance(i, dict)] + if "関門" in results: + out("stopped", "本番のプランが承認ゲートで止まっている(送らない)", code=4) + res = "released" if results and all(r == "完了" for r in results) else "failed" + version = done.get("version") or (done.get("metrics") or {}).get("version") + wid = pl.workflow_id(a.name) + _resume(main, a.name, {"n": a.n, "phase": "settle", "result": res, "version": version, "reason": a.reason}, f"{wid}:resume:settle:{a.n}") + out("ok", f"配布 {a.n} の結果 {res} を送った", result=res, version=version) + + +def cmd_stop(a) -> None: + import durable + import parallel_run as pr_run + + main = main_of() + plan, items, _ = load_plan(main, a.name) + if plan is None: + out("stopped", f"並列の計画 {a.name} の検査に誤りがある", items, 1) + if pl.run_status(main, a.name) in pl.RUNNING: + wid = pl.workflow_id(a.name) + durable.send_to(pl.KIND, pl.identity(main, a.name), wid, {"kind": "stop"}, "inbox", f"{wid}:inbox:stop") + left = [ln.name for ln in plan.lanes if pr_run.relay(main, "stop", ln.name, "--json")[0] not in (0, 3)] + if left: + out("stopped", f"止まらないレーンがある: {', '.join(left)}", [{"lane": n} for n in left], 1, not_stopped=left) + out("ok", f"並列の計画 {a.name} のレーン {len(plan.lanes)} 本へ停止を打った", [{"lane": ln.name} for ln in plan.lanes]) + + +# ---- レーンの側 ---- + + +def cmd_request(a) -> None: + main = main_of() + if pl.run_status(main, a.name) not in pl.RUNNING: + out("stopped", f"並列の計画 {a.name} が動いていない", code=1) + if a.prs and pl.merged_on_main(main, a.prs): + version = pl.released_version(main, a.name, a.prs) + pl.write_out(Path(a.out), {"result": "released", "version": version, "prs": a.prs}) + out("ok", "自分の Pull Request はすでに main にある。依頼を送らずに続きへ進む", result="released", version=version) + rid = pl.send_request(main, a.name, a.lane, a.prs, a.issues, Path(a.state)) + pl.write_out(Path(a.out), {"id": rid, "plan": a.name, "lane": a.lane, "prs": a.prs}) + out("gate", f"配布の依頼 {rid} を送った(配布待ち)。答えは parallel.py await で読む", code=10, id=rid, result="requested") + + +def cmd_await(a) -> None: + main = main_of() + try: + sent = json.loads(Path(getattr(a, "from")).read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + out("stopped", f"依頼の記録を読めない: {exc}", code=2) + if sent.get("result") == "released" and not sent.get("id"): + out("ok", "配布済み(依頼を送っていない)", result="released", version=sent.get("version"), deferred=None) + ans, dfr = pl.read_answer(main, a.name, sent["id"], a.timeout) + if ans is None: + out("stopped", "答えがまだ無い", code=3, deferred=dfr) + res = ans.get("result") + code = {"released": 0, "rejected": 10}.get(res, 1) + status = {0: "ok", 10: "gate"}.get(code, "stopped") + out(status, f"配布の依頼 {sent['id']} の答え: {res}", code=code, result=res, version=ans.get("version"), release=ans.get("release"), reason=ans.get("reason"), deferred=dfr) + + +def parser() -> argparse.ArgumentParser: + ap = argparse.ArgumentParser(prog="parallel.py", description=__doc__.splitlines()[0]) + sub = ap.add_subparsers(dest="cmd", required=True) + for n in ("init", "check", "start", "run", "stop"): + sub.add_parser(n).add_argument("name") + w = sub.add_parser("wait") + w.add_argument("name") + w.add_argument("--after", type=int, default=0) + w.add_argument("--timeout", type=float, default=3600) + p = sub.add_parser("present") + p.add_argument("name") + p.add_argument("n", type=int) + p.add_argument("k", type=int) + p.add_argument("--approval", required=True) + p.add_argument("--out", required=True) + p.add_argument("--first") + c = sub.add_parser("confirm") + c.add_argument("name") + c.add_argument("n", type=int) + c.add_argument("--take", choices=("new", "first")) + s = sub.add_parser("settle") + s.add_argument("name") + s.add_argument("n", type=int) + g = s.add_mutually_exclusive_group(required=True) + g.add_argument("--done") + g.add_argument("--rejected", action="store_true") + s.add_argument("--reason") + r = sub.add_parser("request") + r.add_argument("name") + r.add_argument("--lane", required=True) + r.add_argument("--prs", type=int, nargs="*", default=[]) + r.add_argument("--issues", type=int, nargs="*", default=[]) + r.add_argument("--state", required=True) + r.add_argument("--out", required=True) + aw = sub.add_parser("await") + aw.add_argument("name") + aw.add_argument("--from", required=True) + aw.add_argument("--timeout", type=float, default=0) + return ap + + +def main(argv=None) -> None: + a = parser().parse_args(argv) + { + "init": cmd_init, + "check": cmd_check, + "start": cmd_start, + "run": cmd_run, + "wait": cmd_wait, + "present": cmd_present, + "confirm": cmd_confirm, + "settle": cmd_settle, + "stop": cmd_stop, + "request": cmd_request, + "await": cmd_await, + }[a.cmd](a) + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf/scripts/relay_lib/__init__.py b/plugins/ndf/scripts/relay_lib/__init__.py index 670afb84b..6ab12a907 100644 --- a/plugins/ndf/scripts/relay_lib/__init__.py +++ b/plugins/ndf/scripts/relay_lib/__init__.py @@ -6,8 +6,9 @@ | --- | --- | | `run [claude の引数 ...]` | 端末の前景に常駐し、claude を擬似端末の子として起動する。合図を受けたら子へ `/exit` を入力し、プラグインを更新して次の区間を起動する。ラッパーが要らない起動は本物の claude をそのまま exec する(素通し) | | `stop [<名前>] [--json]` | 名前が無ければ動いているラッパーすべてに停止の合図を置く。名前があればその 1 本だけを止める(#2346) | -| `spawn --name <名前> [--json] -- <最初の入力>` | 統括役に頼み、tmux の中に端末の結び付いていない名前つきのラッパーを起動する(#2346) | -| `ls [--json]` | 同じコンテナのラッパーの名前・状態・承認待ち・区間・時刻・アカウントを 1 つの表(`--json` は 1 行の JSON)で出す(#2346) | +| `spawn --name <名前> [--role helper --plan <計画名>] [--json] -- <最初の入力>` | 統括役に頼み、tmux の中に端末の結び付いていない名前つきのラッパーを起動する(#2346)。`--role helper` は一時レーンで、1 本だけ上限を超えて通る(#2436) | +| `ls [--json]` | 同じコンテナのラッパーの名前・状態・承認待ち・区間・時刻・アカウント・役割を 1 つの表(`--json` は 1 行の JSON)で出す(#2346)。`--json` の `metrics` に統括役と tmux の有無(#2436) | +| `role organizer\|helper --plan <計画名>` / `role --clear` | 作業ディレクトリへ役割のファイルを書く・消す。オーガナイザーの印を持つ 1 本は同時に動かせる数に数えない(#2436) | | `attach <名前>` / `restart <名前> [--json]` | 打った端末を名前つきのラッパーの tmux のセッションへ結び付ける・1 本を止めて起動し直す(#2346) | | `mark` | Stop hook の本体。最後の応答の `ndf-next` のブロックを合図 `next.json` へ写す。ブロックの無い応答が本文の承認待ちで終われば 1 度止めて AskUserQuestion へ戻す | | `limit` | StopFailure hook の本体。子の応答が API の失敗(利用上限など)で終わったことを `limit.json` へ書く(#1389) | @@ -53,7 +54,7 @@ def main(argv: list[str]) -> int: if not argv: print( - "usage: relay.py run|stop|spawn|ls|attach|restart|mark|limit|install|uninstall|status|startup|question open|close|is-child|notice|account add|add-bedrock|check|list|capacity|remove", + "usage: relay.py run|stop|spawn|ls|role|attach|restart|mark|limit|install|uninstall|status|startup|question open|close|is-child|notice|account add|add-bedrock|check|list|capacity|remove", file=sys.stderr, ) return 2 @@ -68,6 +69,7 @@ def main(argv: list[str]) -> int: "stop": lambda: _stop_cmd(rest), "spawn": lambda: hub_client.cmd_spawn(rest), "ls": lambda: hub_client.cmd_ls(rest), + "role": lambda: hub_client.cmd_role(rest), "attach": lambda: hub_client.cmd_attach(rest), "restart": lambda: hub_client.cmd_restart(rest), "install": install.cmd_install, diff --git a/plugins/ndf/scripts/relay_lib/hub.py b/plugins/ndf/scripts/relay_lib/hub.py index 8ccf9925a..45613c8b5 100644 --- a/plugins/ndf/scripts/relay_lib/hub.py +++ b/plugins/ndf/scripts/relay_lib/hub.py @@ -142,6 +142,7 @@ class Wrapper(NamedTuple): question: bool start: dict | None # 最後の start の行 last_at: str | None # log.jsonl の最後の行の時刻 + role: str | None = None # 役割のファイルの役割(`organizer` / `helper`。#2436 の I13) @property def started(self) -> float: @@ -153,6 +154,16 @@ def read_name(d: str) -> str | None: return s or None +ROLE_FILE = "role.json" # 作業ディレクトリの役割(`relay.py role`・`spawn --role`。#2436) +ROLES = ("organizer", "helper") + + +def read_role(d: str) -> dict | None: + """作業ディレクトリの役割のファイル(`{"role", "plan"}`)。無い・形が違えば None。""" + row = load_json(os.path.join(d, ROLE_FILE)) + return row if isinstance(row, dict) and row.get("role") in ROLES else None + + def _last_at(d: str) -> str | None: try: with open(os.path.join(d, LOG_FILE), "rb") as f: @@ -187,6 +198,7 @@ def scan_wrappers() -> list[Wrapper]: os.path.exists(os.path.join(d, QUESTION_FILE)), next(_start_rows(d), None), _last_at(d), + (read_role(d) or {}).get("role"), ) ) return out @@ -214,17 +226,32 @@ class Live(NamedTuple): count: int names: list[str] taken: set[str] # 名前つきで動いている・起動した直後の名前 + helpers: int = 0 # 役割 `helper` の動いているラッパーの数(#2436 の I13) + + +def _role_of(w: Wrapper, registry: dict[str, dict]) -> str | None: + """役割のファイルの役割。無ければ登録簿の行の役割(`spawn --role` で起動した直後の分)。""" + if w.role: + return w.role + row = registry.get(w.name or "") or {} + return row.get("role") if row.get("role") in ROLES else None def live(ws: list[Wrapper], registry: dict[str, dict], sessions: dict[str, dict]) -> Live: """`relay.lock` が取られている作業ディレクトリと、登録簿の行のうち tmux のセッションが在るのに同じ名前の - 作業ディレクトリがまだ `relay.lock` を取っていないもの(起動した直後)の和。重複は名前で除く。""" + 作業ディレクトリがまだ `relay.lock` を取っていないもの(起動した直後)の和。重複は名前で除く。 + + 数(`count`)からは、オーガナイザーの印を持つ動いているラッパーを 1 本だけ外し、役割 `helper` のラッパーも + 外して `helpers` に数える(#2436 の I13。`helper` の上限は `Hub.judge` が見る)。""" running = [w for w in ws if w.running] taken = {w.name for w in running if w.name} pending = sorted(n for n, row in registry.items() if n not in taken and row.get("session", n) in sessions) names = [w.name or "-" for w in running] + pending + roles = [_role_of(w, registry) for w in running] + [registry[n].get("role") for n in pending] + helpers = roles.count("helper") + organizer = 1 if "organizer" in roles else 0 # tmux のセッションの名前も重ねない(tmux が `duplicate session` で断る) - return Live(len(running) + len(pending), names, taken | set(sessions)) + return Live(len(running) + len(pending) - helpers - organizer, names, taken | set(sessions), helpers) def restart_input(name: str, row: dict, w: Wrapper | None) -> tuple[str, list[str]]: @@ -352,6 +379,7 @@ def process(self, req: dict) -> dict: if row is None: return self.refuse(kind, name, Refusal(NOT_FOUND, f"その名前は無い: {name}", 3)) cwd, args = restart_input(name, row, by_name(ws).get(name)) + role = row.get("role") if row.get("role") == "helper" else None elif kind == "spawn": cwd = req.get("cwd") if isinstance(req.get("cwd"), str) and os.path.isdir(req["cwd"]) else os.path.expanduser("~") args = [a for a in req.get("args") or [] if isinstance(a, str)] @@ -361,12 +389,14 @@ def process(self, req: dict) -> dict: args = prompt_args(" ".join(args[1:] if args[0] == "--" else args)) if name is None: name = self.auto_name(now.taken) + role = req.get("role") if req.get("role") == "helper" else None else: return self.refuse(kind, name, Refusal("bad-request", f"知らない依頼の種類: {kind}", 2)) - refusal = self.judge(name, now) + refusal = self.judge(name, now, role) if refusal: return self.refuse(kind, name, refusal) - return self.launch(kind, name, cwd, args, str(req.get("by") or ""), registry) + plan = req.get("plan") if isinstance(req.get("plan"), str) else (registry.get(name) or {}).get("plan") + return self.launch(kind, name, cwd, args, str(req.get("by") or ""), registry, role, plan) @staticmethod def auto_name(taken: set[str]) -> str: @@ -377,25 +407,37 @@ def auto_name(taken: set[str]) -> str: return f"t{n}" @staticmethod - def judge(name, now: Live) -> Refusal | None: - """名前の形(I7)・重複(I2)・上限(I3)。入口に依らず、ここだけで判定する。""" + def judge(name, now: Live, role: str | None = None) -> Refusal | None: + """名前の形(I7)・重複(I2)・上限(I3)。入口に依らず、ここだけで判定する。 + + 役割 `helper` は動いている `helper` が無いときだけ、上限を 1 本超えて通す(#2436 の I13)。""" if not name_ok(name): return Refusal(BAD_NAME, f"名前の形が違う({NAME_RE.pattern}): {name}", 3) if name in now.taken: return Refusal(NAME_TAKEN, f"名前が重なる: {name} は動いている", 3) cap = max_named() - if now.count >= cap: + if role == "helper": + if now.helpers >= 1: + return Refusal(LIMIT, "一時レーン(役割 helper)は同時に 1 本まで", 3) + elif now.count >= cap: return Refusal(LIMIT, f"同時に動かすラッパーの上限 {cap} に達した(動いている: {', '.join(now.names)})", 3) if tmux.binary() is None: return Refusal(NO_TMUX, "tmux が PATH に無い", 3) return None - def launch(self, kind: str, name: str, cwd: str, args: list[str], by: str, registry: dict) -> dict: + def launch( + self, kind: str, name: str, cwd: str, args: list[str], by: str, registry: dict, role: str | None = None, plan: str | None = None + ) -> dict: argv = [sys.executable, launcher_path(), "run", *args] - why = tmux.new_session(name, cwd, argv, {"NDF_RELAY_NAME": name, "NDF_RELAY_SPAWNED": "1"}) + env = {"NDF_RELAY_NAME": name, "NDF_RELAY_SPAWNED": "1"} + if role: + env.update(NDF_RELAY_ROLE=role, NDF_RELAY_PLAN=plan or "") + why = tmux.new_session(name, cwd, argv, env) if why: return self.refuse(kind, name, Refusal(TMUX_FAILED, why, 1)) registry[name] = {"session": name, "cwd": cwd, "args": args, "created_at": stamp(), "by": by} + if role: + registry[name].update(role=role, plan=plan) write_json_atomic(hub_path(REGISTRY), {"version": 1, "entries": registry}) hub_log("started" if kind == "spawn" else "restarted", name, by or None) return hub_result(kind, "ok", f"{name} を起動した(relay.py attach {name} で結び付ける)", name, "started", 0) diff --git a/plugins/ndf/scripts/relay_lib/hub_client.py b/plugins/ndf/scripts/relay_lib/hub_client.py index b62b0c9b5..1cb502343 100644 --- a/plugins/ndf/scripts/relay_lib/hub_client.py +++ b/plugins/ndf/scripts/relay_lib/hub_client.py @@ -28,13 +28,15 @@ # ---------------------------------------------------------------- 依頼 -def put_request(kind: str, name: str | None, cwd: str, args: list[str], by: str) -> str: - """受付箱へ依頼を置き、依頼の id を返す(ファイルは 0600。I8)。環境変数は載せない(決定 2)。""" +def put_request(kind: str, name: str | None, cwd: str, args: list[str], by: str, extra: dict | None = None) -> str: + """受付箱へ依頼を置き、依頼の id を返す(ファイルは 0600。I8)。環境変数は載せない(決定 2)。 + + `extra` は役割(`role`・`plan`。#2436)のように依頼へ足す項目。""" hb.ensure_dirs() rid = hb.request_id() write_json_atomic( hb.hub_path(hb.REQUESTS, f"{rid}.json"), - {"kind": kind, "name": name, "cwd": cwd, "args": args, "by": by, "at": stamp()}, + {"kind": kind, "name": name, "cwd": cwd, "args": args, "by": by, "at": stamp(), **(extra or {})}, ) return rid @@ -52,10 +54,10 @@ def _wait_result(rid: str, seconds: float) -> dict | None: time.sleep(0.1) -def send_request(kind: str, name: str | None, cwd: str, args: list[str], by: str) -> dict | None: +def send_request(kind: str, name: str | None, cwd: str, args: list[str], by: str, extra: dict | None = None) -> dict | None: """依頼を置いて結果を待つ。`NDF_RELAY_HUB_WAIT` 秒で来なければ依頼を消して None。消せなければ統括役が 受け取った後なので、もう 1 度同じ秒数だけ待つ(決定 14)。""" - rid = put_request(kind, name, cwd, args, by) + rid = put_request(kind, name, cwd, args, by, extra) seconds = hb.wait_seconds() got = _wait_result(rid, seconds) if got is not None: @@ -186,15 +188,20 @@ def _split_dashdash(argv: list[str]) -> tuple[list[str], list[str]]: return argv, [] +def _opt(opts: list[str], flag: str) -> str | None: + if flag not in opts: + return None + i = opts.index(flag) + return opts[i + 1] if i + 1 < len(opts) else None + + def cmd_spawn(argv: list[str]) -> int: opts, rest = _split_dashdash(argv) as_json = "--json" in opts - name = None - if "--name" in opts: - i = opts.index("--name") - name = opts[i + 1] if i + 1 < len(opts) else None - if not name: - print("usage: relay.py spawn --name <名前> [--json] -- <最初の入力>", file=sys.stderr) + name = _opt(opts, "--name") + role, plan = _opt(opts, "--role"), _opt(opts, "--plan") + if not name or (role is not None and (role != "helper" or not plan)): + print("usage: relay.py spawn --name <名前> [--role helper --plan <計画名>] [--json] -- <最初の入力>", file=sys.stderr) return 2 if not hb.name_ok(name): return print_result( @@ -202,10 +209,35 @@ def cmd_spawn(argv: list[str]) -> int: ) if not hb.hub_running(): return print_result(no_hub("spawn", name, "統括役が居ない(端末で claude を起動すると統括役になる)"), as_json) - res = send_request("spawn", name, os.getcwd(), hb.prompt_args(" ".join(rest)) if rest else [], "spawn") + extra = {"role": role, "plan": plan} if role else None + res = send_request("spawn", name, os.getcwd(), hb.prompt_args(" ".join(rest)) if rest else [], "spawn", extra) return print_result(res or no_hub("spawn", name, "統括役が答えない"), as_json) +def cmd_role(argv: list[str]) -> int: + """`relay.py role organizer --plan <計画名>` / `relay.py role --clear`。作業ディレクトリの役割のファイルを書く・消す(#2436)。""" + relay_dir = os.environ.get("NDF_RELAY_DIR") + clear = "--clear" in argv + role = next((a for a in argv if not a.startswith("-") and a != _opt(argv, "--plan")), None) + plan = _opt(argv, "--plan") + if not clear and (role not in hb.ROLES or not plan): + print("usage: relay.py role organizer|helper --plan <計画名> | relay.py role --clear", file=sys.stderr) + return 2 + if not relay_dir or not os.path.isdir(relay_dir): + res = {"tool": "relay-role", "status": "stopped", "summary": "ラッパーの外(NDF_RELAY_DIR が無い)", "items": [], "metrics": {}} + print(json.dumps(res, ensure_ascii=False)) + return 1 + path = os.path.join(relay_dir, hb.ROLE_FILE) + if clear: + remove(path) + summary, item = "役割の印を消した", {"role": None, "plan": None} + else: + write_json_atomic(path, {"role": role, "plan": plan, "at": stamp()}) + summary, item = f"役割 {role}(計画 {plan})の印を書いた", {"role": role, "plan": plan} + print(json.dumps({"tool": "relay-role", "status": "ok", "summary": summary, "items": [item], "metrics": {}}, ensure_ascii=False)) + return 0 + + # ---------------------------------------------------------------- ls @@ -215,7 +247,9 @@ def _iso(epoch: float | None) -> str | None: return _dt.datetime.fromtimestamp(epoch, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") -def _ls_item(name: str, w: hb.Wrapper | None, session: dict | None, pending: bool, hub_dir: str | None) -> dict: +def _ls_item( + name: str, w: hb.Wrapper | None, session: dict | None, pending: bool, hub_dir: str | None, role: str | None = None +) -> dict: running = bool(w and w.running) or pending waiting = bool(w and w.running and w.question) state = "awaiting_approval" if waiting else "running" if running else "stopped" @@ -232,6 +266,7 @@ def _ls_item(name: str, w: hb.Wrapper | None, session: dict | None, pending: boo "ndf_version": start.get("plugin_version") if isinstance(start.get("plugin_version"), str) else None, "attached": session["attached"] if session else None, "hub": bool(w and w.running and hub_dir and w.dir == hub_dir), + "role": (w.role if w else None) or role, } @@ -247,7 +282,7 @@ def list_items() -> list[dict]: w = named.get(n) session = sessions.get((registry.get(n) or {}).get("session", n)) pending = session is not None and not (w and w.running) and n in registry - items.append(_ls_item(n, w, session, pending, hub_dir)) + items.append(_ls_item(n, w, session, pending, hub_dir, (registry.get(n) or {}).get("role"))) items += [_ls_item("-", w, None, False, hub_dir) for w in ws if w.running and not w.name] return items @@ -285,6 +320,8 @@ def cmd_ls(argv: list[str]) -> int: "awaiting_approval": states.count("awaiting_approval"), "stopped": states.count("stopped"), "limit": hb.max_named(), + "hub": hb.hub_running(), + "tmux": tmux.binary() is not None, } if "--json" in argv: summary = f"ラッパー {len(items)} 本(動いている {metrics['running']}・承認待ち {metrics['awaiting_approval']}・止まった {metrics['stopped']})" diff --git a/plugins/ndf/scripts/supervise.py b/plugins/ndf/scripts/supervise.py index 7bb50bbd3..fd37f03d4 100755 --- a/plugins/ndf/scripts/supervise.py +++ b/plugins/ndf/scripts/supervise.py @@ -79,6 +79,7 @@ import argparse import json +import os import sys from pathlib import Path @@ -146,7 +147,7 @@ def build_parser(sub) -> None: "一覧を .plans.json へ書く", ) q.add_argument("plans", nargs="*", help="流すプランの JSON(--resume と併せて渡さない)") - q.add_argument("--max", type=int, help="同時に流す本数(既定 3。--resume は前の起動の値)") + q.add_argument("--max", type=int, help="同時に流す本数(既定 3。並列の計画のレーンならレーンのプランの本数。--resume は前の起動の値)") q.add_argument("--resume", metavar="DONE", help="前の起動の done。止まったプランを終えたステップから続け、後続のステージも流す") q.add_argument("--from", dest="from_", action="append", default=[], metavar="PLAN=STEP", help="--resume: 続けるプランの開始のステップ") q.add_argument("--poll", type=float, default=5.0, help="終わりを見る間隔(秒)") @@ -252,7 +253,7 @@ def _cmd_queue(ap: argparse.ArgumentParser, a: argparse.Namespace) -> "NoReturn" emit(queue_resume.cmd_queue_resume(a.resume, a.from_, a.max and max(1, a.max), a.poll, queue.cmd_queue)) if not a.plans or a.from_: ap.error("queue にはプランが要る(--from は --resume と使う)") - emit(queue.cmd_queue(a.plans, max(1, a.max or 3), a.poll, a.then, a.done)) + emit(queue.cmd_queue(a.plans, max(1, a.max or default_max()), a.poll, a.then, a.done)) def _cmd_wait(ap: argparse.ArgumentParser, a: argparse.Namespace) -> "NoReturn": # noqa: F821 @@ -285,6 +286,20 @@ def _cmd_example(ap: argparse.ArgumentParser, a: argparse.Namespace) -> int: } + +def default_max(cwd: str | None = None) -> int: + """`queue` の `--max` を省いたときの本数。並列の計画のレーンならレーンのプランの本数、そうでなければ 3(#2436)。""" + import decl + import parallel_lane + + root = cwd or os.getcwd() + try: + n = parallel_lane.lane_plans(decl.main_dir(root) or Path(root)) + except Exception as exc: # noqa: BLE001 判定できなければ今どおりの 3 で流す(理由は標準エラーへ) + print(f"[supervise] レーンの判定に失敗したため --max の既定 3 で流す: {exc}", file=sys.stderr) + n = None + return n or 3 + def main() -> int: ap = argparse.ArgumentParser( description=__doc__.splitlines()[0], epilog=MAIN_EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter diff --git a/plugins/ndf/scripts/supervise_lib/paths.py b/plugins/ndf/scripts/supervise_lib/paths.py index 99652309f..bc62b4bed 100644 --- a/plugins/ndf/scripts/supervise_lib/paths.py +++ b/plugins/ndf/scripts/supervise_lib/paths.py @@ -162,6 +162,7 @@ def ensure_worktree(plan: dict, sleep=time.sleep) -> str | None: STEPS_PY = f"python3 {HERE / 'release-steps.py'}" VERIFY_PY = f"python3 {HERE / 'release-verification-steps.py'}" MERGED_PY = f"python3 {HERE / 'merged-steps.py'}" +PARALLEL_PY = f"python3 {HERE / 'parallel.py'}" # 並列の計画と進行(#2436) WORKTREE_SETUP = SELF.parent / "worktree-setup.sh" diff --git a/plugins/ndf/scripts/supervise_lib/queue_fill.py b/plugins/ndf/scripts/supervise_lib/queue_fill.py index f7e0ed40a..04818c972 100644 --- a/plugins/ndf/scripts/supervise_lib/queue_fill.py +++ b/plugins/ndf/scripts/supervise_lib/queue_fill.py @@ -20,6 +20,10 @@ QUEUE_PR = re.compile(r"\{queue_pr:([A-Za-z0-9._-]+)\}") # 名前の一致する計画の Pull Request 1 本 QUEUE_RECORD = "{queue_record}" # 計画の `課題` のリリース記録を置いた PR(#2230) QUEUE_STATE = re.compile(r"\{queue_state(\??):([A-Za-z0-9._-]+)\}") # 名前の一致する計画の状態ディレクトリ(#1488)。`?` 付きは無ければ空 +# 名前の一致する完了した計画の状態ディレクトリの `work/prs.txt`(空白区切りの Pull Request)・`work/approved.txt`(承認したコミット)。 +# まとめた配布の本番のプランが、提示のプランの結果を読む(#2436) +QUEUE_WORK = re.compile(r"\{queue_(prs|approved):([A-Za-z0-9._-]+)\}") +WORK_FILES = {"prs": "prs.txt", "approved": "approved.txt"} def plan_pr(item: dict) -> str: @@ -79,6 +83,22 @@ def _fill_states(text: str, states: list[tuple[str, str]], items: list[dict], pl return text, missing +def _fill_work(text: str, works: list[tuple[str, str]], items: list[dict], plan: str) -> tuple[str, list[str]]: + """{queue_prs:<名>}・{queue_approved:<名>} を、名前の一致する完了した計画の `work/` のファイルの中身で置き換える。 + 完了した計画が無い・ファイルが無い・空なら埋めずに欠けの文で返す(#2079 の扱い。そのプランは流さない)。""" + missing = [] + for kind, name in works: + done = [i for i in results_named(name, items, plan) if i.get("result") == "完了" and i.get("plan")] + files = [state_dir_of(str(Path(i["plan"]).absolute())) / "work" / WORK_FILES[kind] for i in done] + got = [f.read_text(encoding="utf-8").strip() for f in files if f.is_file()] + got = [g for g in got if g] + if not got: + missing.append(f"{{queue_{kind}:{name}}}(名前の一致する完了した計画の work/{WORK_FILES[kind]} が無い)") + continue + text = text.replace(f"{{queue_{kind}:{name}}}", got[-1]) + return text, missing + + def _fill_record(text: str, plan: str) -> tuple[str, list[str]]: """{queue_record} を、計画の `課題` のリリース記録を置いた PR の番号で置き換える。見つからなければ理由の文で返す。""" from sprint_close_lib.record_find import find_record # GitHub を読む差し込みを持つ計画だけが読み込む @@ -108,12 +128,14 @@ def fill_queue_pr(plan: str, items: list[dict]) -> str | None: return f"計画を読めない: {e}" names, states = sorted(set(QUEUE_PR.findall(text))), sorted(set(QUEUE_STATE.findall(text))) record = QUEUE_RECORD in text - if not names and not states and not record: + works = sorted(set(QUEUE_WORK.findall(text))) + if not names and not states and not record and not works: return None text, missing_prs = _fill_prs(text, names, items, plan) text, missing_states = _fill_states(text, states, items, plan) + text, missing_works = _fill_work(text, works, items, plan) text, missing_record = _fill_record(text, plan) if record else (text, []) - missing = missing_prs + missing_states + missing_record + missing = missing_prs + missing_states + missing_works + missing_record if missing: return "差し込みを埋める前の計画の結果が見つからない: " + "、".join(missing) write_text_atomic(Path(plan), text) diff --git a/plugins/ndf/scripts/supervise_lib/release_templates.py b/plugins/ndf/scripts/supervise_lib/release_templates.py index f3a7c283e..7d0417fcd 100644 --- a/plugins/ndf/scripts/supervise_lib/release_templates.py +++ b/plugins/ndf/scripts/supervise_lib/release_templates.py @@ -485,3 +485,36 @@ def material_path(repo, approval): # 配布の形(release の form-<形>.md)ごとの雛形。無い形は /ndf:release で配る RELEASE_FORMS = {"package-plugin": plan_release_package_plugin} + + +def present_gate_steps(state: str, repo: str | None, approval: str, prs: str, mode: str, facts_from: str, answer_from: str) -> list[dict]: + """まとめた配布(#2436)の提示のプランの承認ゲート 2: MVV 判定(mvv・note)→ approve の順に置く。 + + 今の本番のプランでは MVV 判定と approve が排他だが、提示のプランは両方を並べる。MVV 判定が通せば承認の記録が + 既にあるため approve は答えの記録を読まずに通り、関門(10)なら提示のプランは承認ゲートで終わる。確定の後は + 一時レーンが答えを `answer_from` へ書いて `--from approve` で続ける。""" + material = material_path(repo, approval) + st = shlex.quote(str(Path(state).resolve())) + steps = mvv_gate_steps( + f"{MVV_PY} check --sprint {st} --gate release --material {shlex.quote(material)} --pr {prs} --mode {mode}" + + (f" --root {shlex.quote(repo)}" if repo else "") + + f" --note {MVV_NOTE}", + f"sh -c 'for p in {prs}; do gh pr comment \"$p\" --body-file {MVV_NOTE} || exit 1; done'", + "approve", + ) + steps.append( + { + "id": "approve", + "type": "run", + "stage": "配布", + "timeout": 120, + **({"cwd": repo} if repo else {}), + "cmd": f"{STEPS_PY} approve --approval {approval} --facts-from {shlex.quote(facts_from)}" + f" --answer-from {shlex.quote(answer_from)} --by user", + "next": "end", + "gate_next": "end", + } + ) + steps.append(handoff_step(str(Path(state).resolve()), GATE_2, "判定のコメント")) + steps.append(reject_step(str(Path(state).resolve()), GATE_2, "mvv", root=repo or ".")) + return steps diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index cee050c15..80825559f 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -14,6 +14,7 @@ from supervise_lib.decl import decl_roots, with_decls from supervise_lib.design_merge import plan_design_merge from supervise_lib.sprint_waves import ( + plan_release_request, sprint_branch, plan_fast_check, plan_fast_impl, @@ -50,6 +51,24 @@ from supervise_lib.sprint_close_steps import _close_step, _retro_step, _spec_pr_steps, refine_steps +def parallel_lane(repo: str) -> tuple[str, str] | None: + """動いている並列の計画のレーンなら (計画名, レーン名)。プランを組む時点に 1 度だけ判定する(#2436 の I12)。""" + import decl + import parallel_lane as pl + + main = decl.main_dir(repo) or Path(repo) + got = pl.lane_of(main) + return (got[0], got[1].name) if got else None + + +def prod_plans(a, repo: str, build, prs: list[str] | None = None) -> dict: + """本番のステージのプラン。並列の計画のレーンなら配布の依頼のプラン、そうでなければ `build()` の本番のプラン。""" + lane = parallel_lane(repo) + if lane: + return {"release-request": plan_release_request(a, repo, lane[0], lane[1], prs)} + return {"release-prod": build()} + + def prod_version(version: str | None) -> str | None: """明示の開発版から本番の明示の版を作る。開発版が無ければ None(本番は開発版のプランの版の記録から読む。#2164)。""" return strip_suffix(version) if version else None @@ -98,7 +117,7 @@ def fast_sprint_plans(a) -> list[dict]: return waves + route_waves(a, repo, first, mvv=a.state) # 開発版のステージは置かない(#1336 の決定 12) waves += [ {"name": "開発版", "plans": {"release": plan_mvv_release(a, repo, a.version, "dev")}, "then_of": first}, - {"name": "本番", "plans": {"release-prod": plan_mvv_release(a, repo, prod_version(a.version), "prod")}, "then_of": first}, + {"name": "本番", "plans": prod_plans(a, repo, lambda: plan_mvv_release(a, repo, prod_version(a.version), "prod")), "then_of": first}, ] return waves @@ -134,7 +153,7 @@ def auto_sprint_plans(a) -> list[dict]: prs = ["{queue_pr:check}"] # 出す版の PR は検査のステージのスプリントの PR(関門 2 の判定のコメントの宛先) return waves + [ {"name": "開発版", "plans": {"release": plan_mvv_release(a, repo, a.version, "dev", prs=prs)}, "then_of": first}, - {"name": "本番", "plans": {"release-prod": plan_mvv_release(a, repo, prod_version(a.version), "prod", prs=prs)}, "then_of": first}, + {"name": "本番", "plans": prod_plans(a, repo, lambda: plan_mvv_release(a, repo, prod_version(a.version), "prod", prs=prs), prs), "then_of": first}, ] @@ -187,7 +206,7 @@ def close_waves(a) -> list[dict]: {"name": "開発版", "plans": {"release": plan_mvv_release(a, repo, a.version, "dev", changed)}, "then_of": "最終の検査"}, { "name": "本番", - "plans": {"release-prod": plan_mvv_release(a, repo, getattr(a, "prod", None), "prod", changed)}, + "plans": prod_plans(a, repo, lambda: plan_mvv_release(a, repo, getattr(a, "prod", None), "prod", changed)), "then_of": "最終の検査", }, close, @@ -284,6 +303,8 @@ def then(name: str) -> dict: release["note"] = normal_gate_2(a) return waves + [release] prod = prod_version(a.version) + if parallel_lane(repo): # レーンは承認ゲート 2 を置かず、配布の依頼を送る(承認ゲート 2 はオーガナイザーが出す。#2436 の I17) + return waves + [release, {"name": "本番", "plans": prod_plans(a, repo, lambda: {})}] return waves + [ release, {"name": GATE_2, "gate": normal_gate_2_stage(a, repo, prod, advise)}, diff --git a/plugins/ndf/scripts/supervise_lib/sprint_waves.py b/plugins/ndf/scripts/supervise_lib/sprint_waves.py index 991e3218b..b62824e85 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_waves.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_waves.py @@ -13,7 +13,7 @@ from gate_answer import GATE_1 from supervise_lib.decl import decl_fields -from supervise_lib.paths import DESIGN_READY, GLOSSARY_PY, MVV_PY, PUSH_DESIGN, SELF, SPEC_COPY_PY, SYNC_DESIGN_BODY, WORKTREE_SETUP +from supervise_lib.paths import DESIGN_READY, GLOSSARY_PY, MVV_PY, PARALLEL_PY, PUSH_DESIGN, SELF, SPEC_COPY_PY, SYNC_DESIGN_BODY, WORKTREE_SETUP from supervise_lib.procedures import DESIGN_RESULTS, pr_step, rebuild_step, requirements_path, sprint_materials, sprint_out, with_record from supervise_lib.release_templates import DEV_SERIES, MVV_NOTE, advise_steps, plan_release, release_names from supervise_lib.plan import sprint_state_path @@ -496,3 +496,88 @@ def plan_mvv_release(a, repo: str, version: str | None, channel: str, condition: if condition: plan["実行の条件"] = condition return plan + + +RELEASE_REQUEST_PHASE = "配布の依頼" + + +def plan_release_request(a, repo: str, plan_name: str, lane: str, prs: list[str] | None = None) -> dict: + """並列の計画のレーンの本番のステージ(#2436 の F6)。本番を流さず、オーガナイザーへ配布の依頼を送る。 + + ステップは `parallel-request` の 1 つで、依頼を送れば 10(承認ゲート)で終わり、自分の Pull Request が + すでに `main` にあれば送らずに 0 で終わる(I15)。`prs` は出す版の Pull Request(`{queue_pr:<名>}` でもよい)。""" + issues = " ".join(str(n) for n in (a.issue or [])) + pr_args = " ".join(prs or []) + cmd = ( + f"{PARALLEL_PY} request {shlex.quote(plan_name)} --lane {shlex.quote(lane)}" + + (f" --prs {pr_args}" if pr_args else "") + + (f" --issues {issues}" if issues else "") + + " --state {state_dir} --out {state_dir}/work/request.txt" + ) + return with_record( + { + "フェーズ": RELEASE_REQUEST_PHASE, + "課題": a.issue, + "モード": a.mode, + "作業場所": repo, + "規則": "", + "並列の計画": plan_name, + "steps": [ + {"id": "parallel-request", "type": "run", "stage": "配布", "timeout": 300, "cmd": cmd, "next": "end"} + ], + } + ) + + +def plan_parallel_release(main, name: str, d, version: str) -> tuple[dict, dict]: + """まとめた配布(#2436)の 1 つの提示の (提示のプラン, 本番のプラン)。`d` は `releases/<連番>/<提示>/`。 + + 提示のプランは開発版の承認資料のステップ(approval-facts・承認資料の説明の欄・他のプラグインの上げ幅)を版上げ無しで + 流し、`parallel.py present` でレーンの内訳を足し、最後に承認ゲート 2(MVV 判定 → approve)を置く。本番のプランは + 承認ゲート 2 を持たず、確定した提示のプランの承認したコミット(`{queue_approved:present}`)から `release/v<版>` を切る。""" + from supervise_lib.decl import apply_decls + from supervise_lib.release_templates import _dev_approval_steps, present_gate_steps + + repo = str(main) + d = Path(d) + seq, k = d.parent.name, d.name + ns = argparse.Namespace( + worktree=repo, repo=repo, base=None, production_branch=None, release=None, issue=[], mode="standard", + version=version, prev_tag=None, mvv=None, advise=None, channel="dev", prs=[], prs_from_queue=False, + test_cmd=None, test_kind=None, test_paths=None, test_all=None, no_reports=None, sync_checks=None, format=None, + glossary_ready=None, strategy=None, test_limits=None, template="sprint", level=None, series=None, branch=None, + ) + apply_decls(ns) + plugin = (ns.release or {}).get("plugin", "ndf") + approval = f"issues/approval-{plugin}-v{version}-parallel-{seq}-{k}.md" + state = d / "answer.json" + present_state = d / "present" # 提示のプランの状態ディレクトリ(`state_dir_of` の置き場と同じ) + facts = _dev_approval_steps(ns, repo, approval, "0", ",".join((ns.release or {}).get("runtimes") or [])) + facts[-1]["next"] = "present" + first = d.parent / "1" / "presented.json" + present = { + "id": "present", + "type": "run", + "stage": "配布", + "timeout": 300, + "cwd": repo, + "cmd": f"{PARALLEL_PY} present {shlex.quote(name)} {seq} {k} --approval {approval} --out {{state_dir}}/work/prs.txt" + + (f" --first {shlex.quote(str(first))}" if k != "1" else ""), + "next": "mvv", + } + gate = present_gate_steps(str(state), repo, approval, "$(cat {state_dir}/work/prs.txt)", "standard", str(present_state), str(state)) + present_plan = with_record( + { + "フェーズ": "配布(提示)", + "課題": [], + "モード": "standard", + "作業場所": repo, + "規則": "", + "並列の計画": name, + "steps": [*facts, present, *gate], + } + ) + release_plan = plan_mvv_release(ns, repo, version, "prod", prs=["{queue_prs:present}"]) + release_plan["起点"] = "{queue_approved:present}" + release_plan["並列の計画"] = name + return present_plan, release_plan diff --git a/plugins/ndf/scripts/tests/test_relay_hub.py b/plugins/ndf/scripts/tests/test_relay_hub.py index 487e30566..bd0a293c3 100644 --- a/plugins/ndf/scripts/tests/test_relay_hub.py +++ b/plugins/ndf/scripts/tests/test_relay_hub.py @@ -248,7 +248,7 @@ def test_ls_json_lists_state_from_files(dirs, capsys): assert rows["lflow"]["state"] == "awaiting_approval" and rows["lflow"]["awaiting_approval"] is True assert rows["lflow"]["sections"] == 3 for it in res["items"]: - assert set(it) == {"name", "state", "awaiting_approval", "sections", "cost_usd", "at", "account", "ndf_version", "attached", "hub"} + assert set(it) == {"name", "state", "awaiting_approval", "sections", "cost_usd", "at", "account", "ndf_version", "attached", "hub", "role"} assert it["cost_usd"] is None and "最初" not in json.dumps(it, ensure_ascii=False) assert res["metrics"] == {"running": 2, "awaiting_approval": 1, "stopped": 0, "limit": 3} assert hc.cmd_ls([]) == 0 From 11016a051c51666076b0b198050d8f7a93ace27e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 03:54:29 +0000 Subject: [PATCH 02/70] =?UTF-8?q?Docs:=20=E4=B8=A6=E5=88=97=E3=81=AE?= =?UTF-8?q?=E8=A8=88=E7=94=BB=E3=81=AE=E4=BD=9C=E3=82=8A=E6=96=B9=E3=81=A8?= =?UTF-8?q?=E3=82=AA=E3=83=BC=E3=82=AC=E3=83=8A=E3=82=A4=E3=82=B6=E3=83=BC?= =?UTF-8?q?=E3=81=AE=E6=89=8B=E9=A0=86=E3=82=92=E8=B6=B3=E3=81=97=E3=80=81?= =?UTF-8?q?=E7=94=A8=E8=AA=9E=E9=9B=86=E3=81=AE=204=20=E8=AA=9E=E3=81=AE?= =?UTF-8?q?=E6=AD=A3=E6=9C=AC=E3=82=92=E5=90=91=E3=81=91=E7=9B=B4=E3=81=99?= =?UTF-8?q?=EF=BC=88#2436=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/glossary/glossary.json | 10 +- .../ndf/scripts/tests/test_parallel_plan.py | 191 ++++++++++++++++++ .../ndf/scripts/tests/test_parallel_run.py | 177 ++++++++++++++++ .../ndf/skills/development-workflow/SKILL.md | 6 + .../references/parallel.md | 58 ++++++ .../development-workflow/references/relay.md | 3 + .../ndf/skills/issue-plan-strategy/SKILL.md | 6 + .../references/parallel-plan.md | 55 +++++ 8 files changed, 501 insertions(+), 5 deletions(-) create mode 100644 plugins/ndf/scripts/tests/test_parallel_plan.py create mode 100644 plugins/ndf/scripts/tests/test_parallel_run.py create mode 100644 plugins/ndf/skills/development-workflow/references/parallel.md create mode 100644 plugins/ndf/skills/issue-plan-strategy/references/parallel-plan.md diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 48f4a453b..608091ad3 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5182,25 +5182,25 @@ "term": "並列の計画", "context": "ndf-workflow", "meaning": "1 つのリポジトリで同時に進めるレーンの一覧と、レーンに共通の決まりを書いた 1 つの文書。オーガナイザーが読む。名前(計画名)を持つ", - "pending_source": "issues/issue-2436-requirements.md" + "source": "plugins/ndf/skills/issue-plan-strategy/references/parallel-plan.md" }, { "term": "レーン", "context": "ndf-workflow", "meaning": "並列の計画の下で、名前つきのラッパー 1 本が進める開発の流れ。最初の入力は開発ループかスプリントの 1 つ", - "pending_source": "issues/issue-2436-requirements.md" + "source": "plugins/ndf/skills/issue-plan-strategy/references/parallel-plan.md" }, { "term": "オーガナイザー", "context": "ndf-workflow", "meaning": "--parallel <計画名> で起動した conductor の役割。レーンを起動・監視し、配布の依頼をまとめて本番の配布を 1 本にする。統括役とは別", - "pending_source": "issues/issue-2436-requirements.md" + "source": "plugins/ndf/skills/development-workflow/references/parallel.md" }, { "term": "配布の依頼", "context": "ndf-workflow", - "meaning": "レーンが承認ゲート 2 の手前で、本番の配布をオーガナイザーへ頼むために送る 1 件の依頼", - "pending_source": "issues/issue-2436-requirements.md" + "meaning": "レーンが承認ゲート 2 の手前で、本番の配布をオーガナイザーへ頼むために送る 1 件のメッセージ(parallel.py request)", + "source": "plugins/ndf/skills/development-workflow/references/parallel.md" }, { "term": "再起動の方針", diff --git a/plugins/ndf/scripts/tests/test_parallel_plan.py b/plugins/ndf/scripts/tests/test_parallel_plan.py new file mode 100644 index 000000000..ca159dc8e --- /dev/null +++ b/plugins/ndf/scripts/tests/test_parallel_plan.py @@ -0,0 +1,191 @@ +"""並列の計画の検査と作成(#2436 の受け入れ条件 1〜3、I1〜I5・I19)と、統括役の数え方(受け入れ条件 7、I13)。""" + +from __future__ import annotations + +import copy +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS / "lib")) +sys.path.insert(0, str(SCRIPTS)) +import parallel_plan as pp # noqa: E402 +import parallel_release as prl # noqa: E402 +from relay_lib import hub as hb # noqa: E402 + +BASE = { + "version": 1, + "name": "p1", + "rules": ["取り込みの衝突は base-sync で解く"], + "lanes": [ + {"name": "lroot", "input": {"kind": "loop", "loop": "lroot", "args": ["--milestones", "26"]}, "plans": 2, "touches": ["plugins/ndf/"]}, + {"name": "lflow", "input": {"kind": "loop", "loop": "lflow", "args": ["--milestones", "23"]}, "plans": 2, "touches": ["docs/"]}, + {"name": "lfac", "input": {"kind": "sprint", "issues": [2400]}, "plans": 2, "touches": ["factory/"], "after": ["lflow"]}, + ], +} + + +def rules_of(data): + _, items, _ = pp.check_data(data) + return {(it["level"], it["rule"]) for it in items} + + +def variant(**edit): + d = copy.deepcopy(BASE) + for k, v in edit.items(): + v(d) if callable(v) else d.__setitem__(k, v) + return d + + +def test_valid_plan_passes(): + plan, items, metrics = pp.check_data(BASE) + assert plan is not None and metrics["errors"] == 0 and items == [] + + +@pytest.mark.parametrize( + "data,rule", + [ + (variant(name="a" * 25), "I1"), + (variant(x=lambda d: d["lanes"][0].__setitem__("name", "p1-rel1")), "I1"), + (variant(x=lambda d: d["lanes"][1].__setitem__("name", "lroot")), "I1"), + (variant(x=lambda d: d["lanes"][0].__setitem__("plans", 3)), "I2"), + (variant(x=lambda d: d["lanes"].append({**d["lanes"][0], "name": "l4", "plans": 0})), "form"), + (variant(x=lambda d: d["lanes"][2].__setitem__("after", ["nope"])), "I3"), + (variant(x=lambda d: (d["lanes"][1].__setitem__("after", ["lfac"]))), "I3"), + (variant(x=lambda d: d["lanes"][1]["input"].__setitem__("args", [])), "I19"), + (variant(x=lambda d: d["lanes"][1]["input"].__setitem__("args", ["--milestones", "26"])), "I19"), + ], +) +def test_errors_carry_rule(data, rule): + assert ("error", rule) in rules_of(data) + + +def test_name_at_limit_passes(): + assert pp.check_data(variant(name="a" * 24))[0] is not None + + +def test_total_plans_boundary(monkeypatch): + assert pp.check_data(BASE)[0] is not None # 合計 6 は上限ちょうど + monkeypatch.setenv("NDF_PARALLEL_MAX_PLANS", "5") + assert ("error", "I2") in rules_of(BASE) + monkeypatch.setenv("NDF_PARALLEL_MAX_PLANS", "zero") + _, _, metrics = pp.check_data(BASE) + assert metrics["max_plans"] == 6 and metrics["notes"] + + +def test_lane_count_over_named_cap(monkeypatch): + monkeypatch.setenv("NDF_RELAY_MAX_NAMED", "2") + assert ("error", "I2") in rules_of(BASE) + + +def test_touch_overlap_is_warning(): + d = variant(x=lambda d: d["lanes"][1].__setitem__("touches", ["plugins/ndf/skills/"])) + plan, items, metrics = pp.check_data(d) + assert plan is not None and metrics["errors"] == 0 and ("warning", "I4") in {(i["level"], i["rule"]) for i in items} + + +def test_ready_lanes_waits_for_dependency(): + plan = pp.check_data(BASE)[0] + assert [ln.name for ln in pp.ready_lanes(plan, set(), set())] == ["lroot", "lflow"] + assert [ln.name for ln in pp.ready_lanes(plan, {"lroot", "lflow"}, {"lflow"})] == ["lfac"] + + +def _git_main(tmp_path: Path) -> Path: + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + return tmp_path + + +def _cli(cwd: Path, *args: str, stdin: str | None = None): + got = subprocess.run([sys.executable, str(SCRIPTS / "parallel.py"), *args], cwd=cwd, input=stdin, capture_output=True, text=True) + return got.returncode, json.loads(got.stdout.strip().splitlines()[-1]) + + +def test_init_writes_plan_and_handoffs_without_copying_rules(tmp_path): + main = _git_main(tmp_path) + code, res = _cli(main, "init", "p1", stdin=json.dumps(BASE)) + assert code == 0, res + d = main / ".ndf" / "parallel" / "p1" + assert (d / "plan.json").is_file() and (d / "plan.md").is_file() + docs = [main / ".ndf" / "handoff" / f for f in ("loop-lroot.md", "loop-lflow.md", "issue-2400.md")] + for p in docs: + text = p.read_text() + assert str(d / "plan.md") in text and BASE["rules"][0] not in text + assert json.loads((d / "plan.json").read_text())["lanes"][2]["handoff"] == "issue-2400" + code, res = _cli(main, "check", "p1") + assert code == 0 and res["metrics"]["errors"] == 0 + + +def test_init_refuses_existing_handoff_and_writes_nothing(tmp_path): + main = _git_main(tmp_path) + (main / ".ndf" / "handoff").mkdir(parents=True) + (main / ".ndf" / "handoff" / "loop-lflow.md").write_text("前からある\n") + code, res = _cli(main, "init", "p1", stdin=json.dumps(BASE)) + assert code == 1 and "loop-lflow.md" in res["summary"] + assert not (main / ".ndf" / "parallel" / "p1").exists() + assert not (main / ".ndf" / "handoff" / "loop-lroot.md").exists() + assert (main / ".ndf" / "handoff" / "loop-lflow.md").read_text() == "前からある\n" + + +def test_init_with_error_writes_nothing(tmp_path): + main = _git_main(tmp_path) + code, res = _cli(main, "init", "p1", stdin=json.dumps(variant(x=lambda d: d["lanes"][1].__setitem__("after", ["lfac"])))) + assert code == 1 and any(i["rule"] == "I3" for i in res["items"]) + assert not (main / ".ndf").exists() + + +def test_run_outside_wrapper_returns_manual_lines(tmp_path, monkeypatch): + main = _git_main(tmp_path) + assert _cli(main, "init", "p1", stdin=json.dumps(BASE))[0] == 0 + monkeypatch.delenv("NDF_RELAY_DIR", raising=False) + code, res = _cli(main, "run", "p1") + assert code == 3 and res["metrics"]["manual"] is True + assert [i["lane"] for i in res["items"]] == ["lroot", "lflow", "lfac"] + + +# ---- 統括役の数え方(I13) ---- + + +def _w(name, role=None): + return hb.Wrapper(f"/x/{name}", name, True, False, None, None, role) + + +def test_organizer_is_not_counted_once(): + now = hb.live([_w("org", "organizer"), _w("a"), _w("b"), _w("c")], {}, {}) + assert now.count == 3 + assert hb.live([_w("o1", "organizer"), _w("o2", "organizer"), _w("a")], {}, {}).count == 2 + + +def test_helper_passes_once_over_cap(monkeypatch): + monkeypatch.setattr(hb.tmux, "binary", lambda: "/usr/bin/tmux") + monkeypatch.setenv("NDF_RELAY_MAX_NAMED", "3") + full = hb.live([_w("org", "organizer"), _w("a"), _w("b"), _w("c")], {}, {}) + assert hb.Hub.judge("p1-rel1", full, "helper") is None + assert hb.Hub.judge("d", full) is not None + with_helper = hb.live([_w("a"), _w("b"), _w("c"), _w("p1-rel1", "helper")], {}, {}) + assert with_helper.count == 3 and hb.Hub.judge("p1-rel2", with_helper, "helper") is not None + + +def test_registry_role_counts_before_role_file(): + now = hb.live([_w("a"), _w("p1-rel1")], {"p1-rel1": {"role": "helper"}}, {}) + assert now.count == 1 and now.helpers == 1 + + +# ---- まとめた配布(I9・I14・I15) ---- + + +def test_range_maps_unrequested_lane_and_orphans(): + plan = pp.check_data(BASE)[0] + reqs = [{"id": "lroot-r1", "lane": "lroot", "prs": [10]}] + got = prl.map_lanes(plan, [10, 11, 12], {11: [2400], 12: []}, reqs) + assert got["lanes"]["lroot"]["requested"] is True and got["lanes"]["lfac"] == {"prs": [11], "requested": False, "requests": []} + assert got["orphans"] == [12] + + +def test_answerable_and_moved(): + pending = [{"id": "a", "prs": [1]}, {"id": "b", "prs": [5]}] + assert prl.answerable(pending, [1, 2]) == ["a"] + assert prl.moved("abc", "abc") is False and prl.moved("abc", "def") is True diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py new file mode 100644 index 000000000..b19993f62 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -0,0 +1,177 @@ +"""並列の進行の耐久ワークフロー(#2436 の受け入れ条件 4・6・8・11・12、I7・I9・I10・I11・I14・I15)。 + +本物の DBOS(SQLite。置き場は根の conftest.py が `NDF_DBOS_DIR` へ向ける)で流し、統括役の CLI は +`NDF_PARALLEL_RELAY` の差し替え(呼び出しを記録し、`ls` の状態をファイルから返す)で、git と配布のプランの作りは +monkeypatch で差し替える。 +""" + +from __future__ import annotations + +import json +import sys +import textwrap +import time +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS / "lib")) +sys.path.insert(0, str(SCRIPTS)) +import dbos # noqa: E402,F401 +import durable # noqa: E402 +import parallel_lane as pl # noqa: E402 +import parallel_plan as pp # noqa: E402 +import parallel_release as prl # noqa: E402 +import parallel_run as prun # noqa: E402 + +PLAN = { + "version": 1, + "name": "p1", + "rules": [], + "lanes": [ + {"name": "la", "input": {"kind": "sprint", "issues": [1]}, "plans": 1, "touches": ["a/"]}, + {"name": "lb", "input": {"kind": "sprint", "issues": [2]}, "plans": 1, "touches": ["b/"], "after": ["la"]}, + ], +} + +FAKE = textwrap.dedent( + """ + import json, os, sys + log, states = os.environ["FAKE_LOG"], os.environ["FAKE_STATES"] + with open(log, "a") as f: + f.write(json.dumps(sys.argv[1:]) + "\\n") + sub = sys.argv[1] + if sub == "ls": + st = json.load(open(states)) if os.path.exists(states) else {} + print(json.dumps({"items": [{"name": n, "state": s} for n, s in st.items()], "metrics": {"hub": True, "tmux": True}})) + else: + print(json.dumps({"items": [{"result": "started"}], "metrics": {}})) + """ +) + + +@pytest.fixture +def env(tmp_path, monkeypatch): + main = tmp_path / "repo" + main.mkdir() + plan = pp.check_data(PLAN)[0] + pp.write_all(main, plan, PLAN) + fake = tmp_path / "fake_relay.py" + fake.write_text(FAKE) + log, states = tmp_path / "relay.log", tmp_path / "states.json" + monkeypatch.setenv("NDF_PARALLEL_RELAY", f"{sys.executable} {fake}") + monkeypatch.setenv("FAKE_LOG", str(log)) + monkeypatch.setenv("FAKE_STATES", str(states)) + monkeypatch.setenv("NDF_PARALLEL_POLL", "0.2") + heads = {"v": "sha1"} + monkeypatch.setattr(prun, "git_head", lambda _m, ref="origin/develop": heads["v"]) + monkeypatch.setattr(prun, "dev_version", lambda _m: "1.2.3") + + def builder(_main, _name, d, _v): + return {"steps": []}, {"steps": []} + + monkeypatch.setattr(prl, "build_plans", lambda m, n, s, k, v, builder_=None: _build(m, n, s, k, v, builder)) + yield {"main": main, "log": log, "states": states, "heads": heads} + durable.close() + + +def _build(m, n, s, k, v, builder): + return prl.__dict__["_orig_build"](m, n, s, k, v, builder) + + +prl._orig_build = prl.build_plans + + +def calls(log: Path, sub: str) -> list[list[str]]: + return [c for c in (json.loads(ln) for ln in log.read_text().splitlines()) if c[0] == sub] if log.exists() else [] + + +def event(main, name, key): + return durable.event(pl.workflow_id(name), key) + + +def until(pred, timeout=30): + end = time.monotonic() + timeout + while time.monotonic() < end: + got = pred() + if got: + return got + time.sleep(0.1) + raise AssertionError("条件が成り立たないまま上限に達した") + + +def start(main): + prun.open_record(main, "p1") + durable.start(durable.WorkflowRef(pl.workflow_id("p1"), 1, "start"), prun.parallel_workflow, str(main), "p1") + + +def spawned(log): + return [c[2] for c in calls(log, "spawn")] + + +def test_flow_spawns_in_order_releases_without_waiting_and_finishes(env, tmp_path): + main, log, states = env["main"], env["log"], env["states"] + start(main) + until(lambda: spawned(log) == ["la"]) # lb は la が止まるまで起動しない(I8) + states.write_text(json.dumps({"la": "running"})) + # la の依頼 1 件で、他のレーンを待たずに提示 1 を組む(I9) + rid = pl.send_request(main, "p1", "la", [10], [1], tmp_path / "st") + ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release" and e) + assert ev["n"] == 1 and ev["k"] == 1 and "--role helper" in ev["spawn"] + d = prl.release_dir(main, "p1", 1, 1) + (d / "presented.json").write_text(json.dumps({"sha": "sha1", "prs": [10, 11]})) + # 先頭が同じなら確定(I14) + durable.send_to(pl.KIND, pl.identity(main, "p1"), pl.workflow_id("p1"), {"n": 1, "phase": "confirm"}, "resume", "c1") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "confirmed") + durable.send_to(pl.KIND, pl.identity(main, "p1"), pl.workflow_id("p1"), {"n": 1, "phase": "settle", "result": "released", "version": "1.2.3"}, "resume", "s1") + ans, _ = until(lambda: pl.read_answer(main, "p1", rid)[0] and pl.read_answer(main, "p1", rid)) + assert ans["result"] == "released" and ans["version"] == "1.2.3" + assert event(main, "p1", "range-1")["prs"] == [10, 11] + states.write_text(json.dumps({"la": "stopped"})) + until(lambda: "lb" in spawned(log)) + states.write_text(json.dumps({"la": "stopped", "lb": "stopped"})) + out = durable.output_of(pl.workflow_id("p1")) + assert out["status"] == "finished" and out["releases"][0]["state"] == "released" + assert spawned(log) == ["la", "lb"] # 1 本のレーンは 1 回だけ起動する(I7) + assert ["role", "--clear"] in calls(log, "role") + + +def test_moved_head_represents_once(env, tmp_path): + main, heads = env["main"], env["heads"] + start(main) + pl.send_request(main, "p1", "la", [10], [1], tmp_path / "st") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release") + (prl.release_dir(main, "p1", 1, 1) / "presented.json").write_text(json.dumps({"sha": "sha1", "prs": [10]})) + heads["v"] = "sha2" + send = lambda msg, key: durable.send_to(pl.KIND, pl.identity(main, "p1"), pl.workflow_id("p1"), msg, "resume", key) # noqa: E731 + send({"n": 1, "phase": "confirm"}, "c1") + ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("represent") and e) + assert ev["k"] == 2 + (prl.release_dir(main, "p1", 1, 2) / "presented.json").write_text(json.dumps({"sha": "sha2", "prs": [10, 12]})) + heads["v"] = "sha3" # 出し直しへの答えでは比べない + send({"n": 1, "phase": "confirm", "take": "first"}, "c2") + ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "confirmed" and e) + assert ev["k"] == 1 and ev["plan"].endswith("/1/1/release.json") + + +def test_stop_ends_workflow_and_stops_lanes(env): + main, log = env["main"], env["log"] + start(main) + until(lambda: spawned(log) == ["la"]) + wid = pl.workflow_id("p1") + durable.send_to(pl.KIND, pl.identity(main, "p1"), wid, {"kind": "stop"}, "inbox", f"{wid}:inbox:stop") + out = durable.output_of(wid) + assert out["status"] == "stopped" and ["stop", "la", "--json"] in calls(log, "stop") + + +def test_send_request_is_idempotent(env, tmp_path): + main = env["main"] + start(main) + st = tmp_path / "st" + a = pl.send_request(main, "p1", "la", [10], [1], st) + b = pl.send_request(main, "p1", "la", [10], [1], st) + assert a == b and (st / "work" / "request-id").read_text().strip() == a + until(lambda: (event(main, "p1", "lane-la") or {}).get("received") == 1) + time.sleep(0.6) + assert event(main, "p1", "lane-la")["received"] == 1 diff --git a/plugins/ndf/skills/development-workflow/SKILL.md b/plugins/ndf/skills/development-workflow/SKILL.md index 82d180123..1d98511f8 100644 --- a/plugins/ndf/skills/development-workflow/SKILL.md +++ b/plugins/ndf/skills/development-workflow/SKILL.md @@ -219,6 +219,12 @@ pace: fast そのときは `normal` で進める。進め方ごとのフロー・`fast` の工程の区分・条件・設定・トリガー・記録の読み方は [references/pace.md](references/pace.md) にある。 +## 並列開発(`--parallel`) + +**引数に `--parallel <計画名>` があれば、判定の手順へ進まず [references/parallel.md](references/parallel.md) の手順で進める。** +このセッションはオーガナイザーになり、並列の計画のレーンを起動・監視し、本番の配布を 1 本にまとめる。 +並列の計画の作り方は [issue-plan-strategy の parallel-plan.md](../issue-plan-strategy/references/parallel-plan.md) にある。 + ## 開発ループ(`--loop`) **引数に `--loop [<ループ名>]` があれば、判定の手順へ進まず [references/loop.md](references/loop.md) の手順で進める。** diff --git a/plugins/ndf/skills/development-workflow/references/parallel.md b/plugins/ndf/skills/development-workflow/references/parallel.md new file mode 100644 index 000000000..a612783a7 --- /dev/null +++ b/plugins/ndf/skills/development-workflow/references/parallel.md @@ -0,0 +1,58 @@ +# 並列開発のオーガナイザー(`--parallel <計画名>`) + +並列の計画([issue-plan-strategy の parallel-plan.md](../../issue-plan-strategy/references/parallel-plan.md) で作る)を、 +1 行で始める。 + +```text +/goal /ndf:development-workflow --parallel p1 -- 終わり: [ndf-parallel] finished p1 の行を出したとき +``` + +このセッションの conductor が**オーガナイザー**になる。オーガナイザーは作業を持たない。レーンの起動・観測・配布のまとめは +耐久ワークフロー(`parallel.py run`)が、配布のプランの実行は一時レーンが、答えの後の処理は持ち主のレーンが行う。 +オーガナイザーが打つのは、問いと答え・一時レーンの起動・終わったレーンを起こす 1 行だけである。再開のコマンドも同じ 1 行である。 + +## 手順 + +1. `python3 "$NDF_SCRIPTS/parallel.py" start p1` を打つ。検査に誤りがあればレーンを 1 本も起動せずに 1 で終わる。 + ラッパーの外・tmux が無い・統括役が居ないときは 3 で終わり、`items` にレーンごとの手で起動する 1 行が並ぶ。 + その 1 行を示して終える +2. `metrics.next` の `parallel.py wait p1` を背景で打ち、完了の通知を待つ([waiting.md](waiting.md) の形。前景の sleep で待たない) +3. 返った `metrics.action` ごとに次の 1 手を打ち、`wait p1 --after ` を背景で打ち直す + +| `wait` が返したもの | すること | +| --- | --- | +| `notice` | 承認待ちのレーンの `metrics.attach` の 1 行を出す | +| `questions` | 届いている依頼(設計の承認・承認ゲート 2・承認ゲートの外の問い)を区分ごとにまとめて 1 回で問う | +| `release` / `confirmed` | `metrics.spawn` の 1 行(一時レーンの起動)を打つ。一時レーンが `metrics.pipeline` を流し、結果を返す | +| `finished` / `stopped` | レーンごとの結果(配布した版・止まった理由)を要約し、`[ndf-parallel] finished p1` の行を出して終える | +| 終了コード 3 | 同じ `wait` を打ち直す | + +承認ゲート 2 は、提示 1 なら「承認」「差し戻す」で問い、`parallel.py confirm p1 <連番>` か +`parallel.py settle p1 <連番> --rejected` を打つ。出し直しの提示(`represent: true`)なら加わった範囲を添えて +「新しい範囲で出す」「最初の範囲だけ出す」「差し戻す」で問い、`confirm --take new` / `--take first` / `settle --rejected` を打つ。 + +## まとめた配布 + +配布の依頼が届いた時点で流れている配布が無ければ、他のレーンを待たずに直ちに配布を組む。中身は依頼の集合ではなく、 +承認資料を作る時点の `origin/develop` のコミットまでの範囲で決まる。範囲に入ったレーンは、依頼していなくても結果を受ける。 +承認の答えの時点で `develop` が進んでいれば、加わった範囲を添えて同じ承認ゲート 2 を 1 回だけ出し直す。 +流れている配布は 1 本までで、範囲に入らない依頼は今の配布が終わった直後の次の配布になる。 + +## レーンの側 + +並列の計画の下のレーン(`NDF_RELAY_NAME` が動いている計画のレーンにある)では、スプリントの本番のステージが +配布の依頼のプラン(ステップ `parallel-request` の 1 つ)になる。依頼を送ったら承認ゲートとして終わり、レーンは答えを待たずに +次の周回へ進む。答えは `parallel.py await p1 --from <依頼の記録> --timeout 0` で待たずに読む。自分の Pull Request が +すでに `main` にあれば、依頼を送らずに配布済みとして続きへ進む。レーンで `supervise.py queue` の `--max` を省くと、 +並列の計画のそのレーンのプランの本数で流す。 + +## 止める + +`python3 "$NDF_SCRIPTS/parallel.py" stop p1` で計画の全レーンへ停止を打ち、耐久ワークフローを終える。止まらないレーンがあれば +名前を返して 1 で終わる。1 本だけを止めるのは `relay.py stop <名前>` である。 + +## 数え方 + +オーガナイザーのセッションは `relay.py role organizer --plan p1` の印を持ち、同時に動かせる数(`NDF_RELAY_MAX_NAMED`)に数えない。 +既定のままで、オーガナイザー 1 本とレーン 3 本が同時に動く。一時レーン(`relay.py spawn --role helper`)は同時に 1 本までで、 +上限を 1 本だけ超えて通る。 diff --git a/plugins/ndf/skills/development-workflow/references/relay.md b/plugins/ndf/skills/development-workflow/references/relay.md index 497be0911..6145769d5 100644 --- a/plugins/ndf/skills/development-workflow/references/relay.md +++ b/plugins/ndf/skills/development-workflow/references/relay.md @@ -105,6 +105,9 @@ `relay.py ls` で 3 本の状態と承認待ちが 1 つの表に出て、`relay.py stop lroot` で 1 本だけを止められる。 +並列開発をまとめて始めるときは、起動のブロックを並べずに並列の計画を書き([issue-plan-strategy の parallel-plan.md](../../issue-plan-strategy/references/parallel-plan.md))、 +`--parallel <計画名>` でオーガナイザーに起動させる([parallel.md](parallel.md))。本番の配布がレーンどうしでぶつからない。 + | 操作 | 効き目 | | --- | --- | | 統括役が居るときに端末で `claude` | 統括役に起動を頼み、tmux の名前つきのラッパーへ結び付く。名前は `NDF_RELAY_NAME`、無ければ `t2`・`t3`… | diff --git a/plugins/ndf/skills/issue-plan-strategy/SKILL.md b/plugins/ndf/skills/issue-plan-strategy/SKILL.md index 93072a05d..7b42859f6 100644 --- a/plugins/ndf/skills/issue-plan-strategy/SKILL.md +++ b/plugins/ndf/skills/issue-plan-strategy/SKILL.md @@ -46,6 +46,12 @@ allowed-tools: 書き換え)は [parallel-work.md](../development-workflow/references/parallel-work.md) にある。 +## 並列の計画 + +1 つのリポジトリで複数の開発ループかスプリントを tmux のレーンとして同時に進めるときは、並列の計画を 1 度書く。 +レーンごとの名前・最初の入力・触る場所・プランの本数・依存を 1 つの文書に置き、レーンの数だけ引継ぎ文書を作る +(`parallel.py init <計画名>`)。形と検査は [references/parallel-plan.md](references/parallel-plan.md) にある。 + ## Step 0: 作成フェーズか実行フェーズか判定 最初に **既に plan ファイルが存在するか** で判定する。skill 内で `Glob` を使うのが第一選択 (例: `Glob('issues/*PLAN42*')`)。shell で確認する場合は: diff --git a/plugins/ndf/skills/issue-plan-strategy/references/parallel-plan.md b/plugins/ndf/skills/issue-plan-strategy/references/parallel-plan.md new file mode 100644 index 000000000..8f06d2f39 --- /dev/null +++ b/plugins/ndf/skills/issue-plan-strategy/references/parallel-plan.md @@ -0,0 +1,55 @@ +# 並列の計画を作る + +1 つのリポジトリで複数の開発ループかスプリントを同時に進めるときに、**並列の計画**を 1 度書く。並列の計画は +レーンの一覧と、レーンに共通の決まりを 1 つの文書に持つ。オーガナイザー(`--parallel <計画名>` で起動した conductor)が +これを読んでレーンを起動する。起動の手順は [development-workflow の parallel.md](../../development-workflow/references/parallel.md) にある。 + +## 例 + +3 本の開発ループ `lroot`・`lflow` と、`lflow` が終わってから始めるスプリント 1 本(課題 2400)を同時に進める。 + +```bash +python3 "$NDF_SCRIPTS/parallel.py" init p1 <<'EOF' +{"version": 1, "rules": ["取り込みの衝突は base-sync で解く"], + "lanes": [ + {"name": "lroot", "input": {"kind": "loop", "loop": "lroot", "args": ["--milestones", "26"]}, "plans": 2, "touches": ["plugins/ndf/"]}, + {"name": "lflow", "input": {"kind": "loop", "loop": "lflow", "args": ["--milestones", "23"]}, "plans": 2, "touches": ["docs/"]}, + {"name": "lfac", "input": {"kind": "sprint", "issues": [2400]}, "plans": 2, "touches": ["factory/"], "after": ["lflow"]} + ]} +EOF +``` + +`init` は検査を通った下書きから、メインディレクトリの `.ndf/parallel/p1/plan.json`(正本)・`plan.md`(人が読む描画)と、 +レーンの数だけの引継ぎ文書(`.ndf/handoff/loop-lroot.md`・`loop-lflow.md`・`issue-2400.md`)を書く。 +どれもコミットしない。 + +## レーンごとに決めること + +| 項目 | 意味 | +| --- | --- | +| `name` | レーン名。ラッパーの名前になる(英小文字・数字・`-`・`_`、32 字まで。`-rel` と数字で終わる名前は一時レーンの名前のため使えない) | +| `input` | 最初の入力。`{"kind": "loop", "loop": <ループ名>, "args": [...]}` か `{"kind": "sprint", "issues": [<番号>...]}` | +| `plans` | 同時に流すプランの本数(1 以上) | +| `touches` | 触る場所(パスか glob) | +| `after` | 始める条件。ここに並べたレーンがすべて止まってから起動する | + +計画名は 24 字まで。レーンに共通の決まり(プランの本数の配分・触る場所・配布の担当)は `plan.md` の 1 箇所に置き、 +引継ぎ文書へは写さない。引継ぎ文書は計画名・レーン名・`plan.md` のパスと、レーンの開始のコマンドだけを持つ。 + +## 検査 + +`init` は書く前に、`parallel.py check <計画名>` は書いた後に、同じ検査を行う。結果は統一 JSON レポートで、 +`items[].rule` に誤りの種類を持つ。 + +| 種類 | 誤りになるもの | +| --- | --- | +| `I1` | 計画名・レーン名の形、レーン名の重なり、一時レーンの名前の形 | +| `I2` | レーンの数が `NDF_RELAY_MAX_NAMED`(既定 3)を超える、プランの本数の合計が `NDF_PARALLEL_MAX_PLANS`(既定 6)を超える | +| `I3` | 依存先が計画に無い、依存が循環する | +| `I19` | レーンの間で課題かマイルストーンが重なる(`--milestones` の無いループのレーンは、ほかのレーンと重なるものとする) | +| `I4`(警告) | 触る場所が重なるレーンの組。終了コードは 0 のまま | + +誤りがあれば終了コードは 1 で、`init` は 1 つも書かない。同じ名前の引継ぎ文書か計画が既にあれば、 +`init` は上書きせずに名前を返して止まる。名前を変えるか、在る文書を片付けてから打ち直す。 + +起動の前なら `plan.json` を手で直してよい。直したら `parallel.py check <計画名>` を打ち直す。 From 9905175f62f036390dca713ca82f10abbfc9f278 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 03:55:12 +0000 Subject: [PATCH 03/70] =?UTF-8?q?Feat:=20=E9=85=8D=E5=B8=83=E3=81=AE?= =?UTF-8?q?=E4=BE=9D=E9=A0=BC=E3=81=AE=E3=83=A1=E3=83=83=E3=82=BB=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=81=AE=E5=BD=A2=E3=82=92=E5=A5=91=E7=B4=84=E3=81=AB?= =?UTF-8?q?=E7=BD=AE=E3=81=8D=E3=80=81=E6=9B=B8=E3=81=8D=E6=89=8B=E3=81=AE?= =?UTF-8?q?=E6=A4=9C=E6=9F=BB=E3=81=A7=E7=B8=9B=E3=82=8B=EF=BC=88#2436?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 8 +++--- .../ndf/contracts/forms/parallel-request.json | 13 ++++++++++ plugins/ndf/scripts/lib/parallel_lane.py | 26 +++++++++++++++++++ .../tests/test_parallel_request_form.py | 25 ++++++++++++++++++ 4 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 plugins/ndf/contracts/forms/parallel-request.json create mode 100644 plugins/ndf/scripts/tests/test_parallel_request_form.py diff --git a/docs/glossary.md b/docs/glossary.md index 9d799a84f..eda7811bc 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -362,10 +362,10 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 登録簿 | `relay_registry` | 統括役だけが書く、名前つきのラッパーの名前と cwd(起動元のリポジトリ)・最初の引数・tmux のセッションの表(registry.json。作業ディレクトリのパスは持たない) | — | — | `docs/specifications/ndf-relay-multi-session-supervisor.md` | | 起動の依頼 | `spawn_request` | 頼む側が統括役の受付箱へ置く 1 件のファイル。起動・やり直しの種類と名前・作業ディレクトリ・引数を持つ | — | — | `docs/specifications/ndf-relay-multi-session-supervisor.md` | | 起動のブロック | `spawn_block` | conductor の応答に置く、情報文字列が ndf-spawn <名前> の囲み。中身が最初の入力になる | — | — | `docs/specifications/ndf-relay-multi-session-supervisor.md` | -| 並列の計画 | — | 1 つのリポジトリで同時に進めるレーンの一覧と、レーンに共通の決まりを書いた 1 つの文書。オーガナイザーが読む。名前(計画名)を持つ | — | — | — | -| レーン | — | 並列の計画の下で、名前つきのラッパー 1 本が進める開発の流れ。最初の入力は開発ループかスプリントの 1 つ | — | — | — | -| オーガナイザー | — | --parallel <計画名> で起動した conductor の役割。レーンを起動・監視し、配布の依頼をまとめて本番の配布を 1 本にする。統括役とは別 | — | — | — | -| 配布の依頼 | — | レーンが承認ゲート 2 の手前で、本番の配布をオーガナイザーへ頼むために送る 1 件の依頼 | — | — | — | +| 並列の計画 | — | 1 つのリポジトリで同時に進めるレーンの一覧と、レーンに共通の決まりを書いた 1 つの文書。オーガナイザーが読む。名前(計画名)を持つ | — | — | `plugins/ndf/skills/issue-plan-strategy/references/parallel-plan.md` | +| レーン | — | 並列の計画の下で、名前つきのラッパー 1 本が進める開発の流れ。最初の入力は開発ループかスプリントの 1 つ | — | — | `plugins/ndf/skills/issue-plan-strategy/references/parallel-plan.md` | +| オーガナイザー | — | --parallel <計画名> で起動した conductor の役割。レーンを起動・監視し、配布の依頼をまとめて本番の配布を 1 本にする。統括役とは別 | — | — | `plugins/ndf/skills/development-workflow/references/parallel.md` | +| 配布の依頼 | — | レーンが承認ゲート 2 の手前で、本番の配布をオーガナイザーへ頼むために送る 1 件のメッセージ(parallel.py request) | — | — | `plugins/ndf/skills/development-workflow/references/parallel.md` | | 再起動の方針 | — | conductor が工程境界で会話を切るかを決める規則の組。現行方式と試行方式の 2 つ | — | — | `docs/specifications/ndf-context-restart-policy.md` | | 現行方式 | — | 運用上限 200K を超えたら次の工程を止め、4 つのカットポイントではコンテキスト量に関わらず ndf-next を出す再起動の方針。既定 | — | — | `docs/specifications/ndf-context-restart-policy.md` | | 試行方式 | — | カットポイントを再起動を判断できる境界として扱い、判断開始の値・運用上限・容量で継続か再起動かを決める再起動の方針。明示的に選んだときだけ働く | — | — | `docs/specifications/ndf-context-restart-policy.md` | diff --git a/plugins/ndf/contracts/forms/parallel-request.json b/plugins/ndf/contracts/forms/parallel-request.json new file mode 100644 index 000000000..213b02b11 --- /dev/null +++ b/plugins/ndf/contracts/forms/parallel-request.json @@ -0,0 +1,13 @@ +{ + "name": "parallel-request", + "summary": "並列の計画の下のレーンが、承認ゲート 2 の手前で本番の配布をオーガナイザーへ頼むために送る 1 件のメッセージ(配布の依頼)。耐久ワークフロー parallel-<計画名> の話題 inbox へ、冪等の鍵 :inbox:<依頼> で送る。答えはイベント answer-<依頼>(result は released / rejected / failed)で 1 回だけ返り、次の配布へ回した知らせはイベント defer-<依頼> で返る", + "rule": "kind は request、id は <レーン名>-r<数字 12 桁>、lane はレーン名、prs と issues は整数の配列、sprint_state はプランの状態ディレクトリ、at は ISO 8601 の時刻。id のレーン名は lane と一致する", + "writers": ["plugins/ndf/scripts/parallel.py", "plugins/ndf/scripts/lib/parallel_lane.py"], + "readers": ["plugins/ndf/scripts/lib/parallel_run.py"], + "cases": [ + {"message": {"kind": "request", "id": "lroot-r000000000042", "lane": "lroot", "prs": [2420], "issues": [2346], "sprint_state": "/w/.ndf/sprints/s1/8-release-prod", "at": "2026-11-02T13:00:00Z"}, "valid": true}, + {"message": {"kind": "request", "id": "lroot-r000000000042", "lane": "lflow", "prs": [], "issues": [], "sprint_state": "/w", "at": "2026-11-02T13:00:00Z"}, "valid": false}, + {"message": {"kind": "request", "id": "lroot-r42", "lane": "lroot", "prs": [1], "issues": [], "sprint_state": "/w", "at": "2026-11-02T13:00:00Z"}, "valid": false}, + {"message": {"kind": "request", "id": "lroot-r000000000042", "lane": "lroot", "prs": ["1"], "issues": [], "sprint_state": "/w", "at": "2026-11-02T13:00:00Z"}, "valid": false} + ] +} diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py index 129fb11ce..b59eac2ee 100644 --- a/plugins/ndf/scripts/lib/parallel_lane.py +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -9,6 +9,7 @@ import json import os +import re import secrets import subprocess import time @@ -115,6 +116,31 @@ def request_id(state: Path, lane: str) -> str: return rid +REQUEST_ID_RE = re.compile(r"^([a-z0-9][a-z0-9_-]{0,31})-r\d{12}$") + + +def request_errors(msg: object) -> list[str]: + """配布の依頼のメッセージの形の誤り(`contracts/forms/parallel-request.json`)。空なら形に合う。""" + if not isinstance(msg, dict): + return ["オブジェクトでない"] + errs = [] + if msg.get("kind") != "request": + errs.append("kind が request でない") + m = REQUEST_ID_RE.match(str(msg.get("id") or "")) + if not m: + errs.append("id が <レーン名>-r<数字 12 桁> でない") + elif m.group(1) != msg.get("lane"): + errs.append("id のレーン名が lane と違う") + for k in ("prs", "issues"): + v = msg.get(k) + if not isinstance(v, list) or not all(isinstance(x, int) and not isinstance(x, bool) for x in v): + errs.append(f"{k} が整数の配列でない") + for k in ("sprint_state", "at"): + if not isinstance(msg.get(k), str) or not msg.get(k): + errs.append(f"{k} が空でない文字列でない") + return errs + + def send_request(main: Path, name: str, lane: str, prs: list[int], issues: list[int], state: Path) -> str: """配布の依頼を話題 `inbox` へ送る。冪等の鍵は `:inbox:<依頼>`。依頼の ID を返す。""" import durable diff --git a/plugins/ndf/scripts/tests/test_parallel_request_form.py b/plugins/ndf/scripts/tests/test_parallel_request_form.py new file mode 100644 index 000000000..9d2ae7057 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_parallel_request_form.py @@ -0,0 +1,25 @@ +"""配布の依頼のメッセージの形(`contracts/forms/parallel-request.json`)を、書き手の検査と送るメッセージで縛る(#2436)。""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts" / "lib")) +import parallel_lane as pl # noqa: E402 + +FORM = json.loads((ROOT / "contracts" / "forms" / "parallel-request.json").read_text(encoding="utf-8")) + + +@pytest.mark.parametrize("case", FORM["cases"], ids=lambda c: str(c["valid"])) +def test_cases(case): + assert (pl.request_errors(case["message"]) == []) is case["valid"] + + +def test_request_id_is_stable_and_well_formed(tmp_path): + rid = pl.request_id(tmp_path, "lroot") + assert pl.request_id(tmp_path, "lroot") == rid and pl.REQUEST_ID_RE.match(rid) From b33d5e59542fb692d045b936c248f011133bd9ad Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 03:55:47 +0000 Subject: [PATCH 04/70] =?UTF-8?q?Test:=20=E3=83=AC=E3=83=BC=E3=83=B3?= =?UTF-8?q?=E3=81=AE=E5=88=A4=E5=AE=9A=E3=81=A8=20queue=20=E3=81=AE?= =?UTF-8?q?=E6=9C=AC=E6=95=B0=E3=81=AE=E6=97=A2=E5=AE=9A=E3=82=92=E7=B8=9B?= =?UTF-8?q?=E3=82=8B=EF=BC=88#2436=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../ndf/scripts/tests/test_parallel_lane.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 plugins/ndf/scripts/tests/test_parallel_lane.py diff --git a/plugins/ndf/scripts/tests/test_parallel_lane.py b/plugins/ndf/scripts/tests/test_parallel_lane.py new file mode 100644 index 000000000..a1ed17bb7 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_parallel_lane.py @@ -0,0 +1,56 @@ +"""レーンの判定(#2436 の I12)と、レーンの `queue --max` の既定(プランの本数)。""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS / "lib")) +sys.path.insert(0, str(SCRIPTS)) +import parallel_lane as pl # noqa: E402 +import parallel_plan as pp # noqa: E402 + +PLAN = { + "version": 1, + "name": "p1", + "rules": [], + "lanes": [{"name": "la", "input": {"kind": "sprint", "issues": [1]}, "plans": 2, "touches": ["a/"]}], +} + + +def _main(tmp_path: Path) -> Path: + d = pp.plan_dir(tmp_path, "p1") + d.mkdir(parents=True) + (d / "plan.json").write_text(json.dumps(PLAN)) + return tmp_path + + +def test_lane_only_while_plan_is_running(tmp_path): + main = _main(tmp_path) + running = lambda _m, _n: "PENDING" # noqa: E731 + assert pl.lane_of(main, "la", status=running)[1].plans == 2 + assert pl.lane_of(main, "lb", status=running) is None + assert pl.lane_of(main, "la", status=lambda _m, _n: "SUCCESS") is None + assert pl.lane_of(main, "la", status=lambda _m, _n: None) is None + + +def test_queue_max_default(tmp_path, monkeypatch): + import supervise + + main = _main(tmp_path) + monkeypatch.setattr(pl, "lane_plans", lambda m, lane=None: 2 if Path(m) == main else None) + assert supervise.default_max(str(main)) == 2 + assert supervise.default_max(str(tmp_path / "other")) == 3 + + +def test_non_lane_sprint_keeps_prod_plan(monkeypatch): + from supervise_lib import sprint + + monkeypatch.setattr(sprint, "parallel_lane", lambda repo: None) + assert list(sprint.prod_plans(None, "/r", lambda: {"x": 1})) == ["release-prod"] + monkeypatch.setattr(sprint, "parallel_lane", lambda repo: ("p1", "la")) + monkeypatch.setattr(sprint, "plan_release_request", lambda a, repo, plan, lane, prs=None: {"フェーズ": "配布の依頼", "plan": plan, "lane": lane}) + got = sprint.prod_plans(None, "/r", lambda: {"x": 1}) + assert got == {"release-request": {"フェーズ": "配布の依頼", "plan": "p1", "lane": "la"}} From 1028c80cd3a11532ff2d9cb50430af13cf43f9f9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 03:56:13 +0000 Subject: [PATCH 05/70] =?UTF-8?q?Style:=20=E4=B8=A6=E5=88=97=E3=81=AE?= =?UTF-8?q?=E8=A8=88=E7=94=BB=E3=81=AE=E3=82=B9=E3=82=AF=E3=83=AA=E3=83=97?= =?UTF-8?q?=E3=83=88=E3=82=92=E6=95=B4=E5=BD=A2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?#2436=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_plan.py | 14 +++++- plugins/ndf/scripts/lib/parallel_run.py | 32 +++++++++++-- plugins/ndf/scripts/parallel.py | 39 +++++++++++++-- plugins/ndf/scripts/relay_lib/hub_client.py | 4 +- plugins/ndf/scripts/supervise.py | 2 +- plugins/ndf/scripts/supervise_lib/sprint.py | 12 ++++- .../ndf/scripts/supervise_lib/sprint_waves.py | 48 +++++++++++++++---- .../ndf/scripts/tests/test_parallel_lane.py | 4 +- .../ndf/scripts/tests/test_parallel_plan.py | 9 +++- .../ndf/scripts/tests/test_parallel_run.py | 9 +++- 10 files changed, 143 insertions(+), 30 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_plan.py b/plugins/ndf/scripts/lib/parallel_plan.py index c01a1fbdf..2c906bee4 100644 --- a/plugins/ndf/scripts/lib/parallel_plan.py +++ b/plugins/ndf/scripts/lib/parallel_plan.py @@ -222,7 +222,15 @@ def check_data(data: object) -> tuple[Optional[ParallelPlan], list[dict], dict]: items.append({"level": "error", "rule": rule, "at": loc or None, "message": e["msg"]}) if plan is not None: for ov in touch_overlaps(plan): - items.append({"level": "warning", "rule": "I4", "at": "lanes", "message": f"触る場所が重なる: {'・'.join(ov['lanes'])}({', '.join(ov['touches'])})", **ov}) + items.append( + { + "level": "warning", + "rule": "I4", + "at": "lanes", + "message": f"触る場所が重なる: {'・'.join(ov['lanes'])}({', '.join(ov['touches'])})", + **ov, + } + ) metrics = { "errors": sum(1 for it in items if it["level"] == "error"), "warnings": sum(1 for it in items if it["level"] == "warning"), @@ -252,7 +260,9 @@ def render_md(plan: ParallelPlan, progress: Optional[dict] = None) -> str: lines = [f"# 並列の計画 {plan.name}", "", "`plan.json` から描画した。正本は `plan.json` である。", "", "## レーン", ""] lines += ["| レーン | 最初の入力 | プランの本数 | 触る場所 | 依存先 | 引継ぎ文書 |", "| --- | --- | --- | --- | --- | --- |"] for ln in plan.lanes: - first = f"ループ `{ln.input.loop}`" if isinstance(ln.input, LoopInput) else "スプリント " + " ".join(f"#{n}" for n in ln.input.issues) + first = ( + f"ループ `{ln.input.loop}`" if isinstance(ln.input, LoopInput) else "スプリント " + " ".join(f"#{n}" for n in ln.input.issues) + ) if ln.input.args: first += " `" + " ".join(ln.input.args) + "`" lines.append( diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 08e6056e5..16fb8d843 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -233,7 +233,14 @@ def open_release(self) -> None: self.stop = True self.releases.append({"n": n, "state": "failed", "reason": built["error"]}) return - rel = {"n": n, "state": "presented", "version": built["version"], "dirs": [built["dir"]], "presented": [], "requests": [r["id"] for r in self.pending]} + rel = { + "n": n, + "state": "presented", + "version": built["version"], + "dirs": [built["dir"]], + "presented": [], + "requests": [r["id"] for r in self.pending], + } self.releases.append(rel) self.current = rel self.emit_release(rel, 1, represent=False) @@ -273,7 +280,9 @@ def confirm(self, rel: dict, take: Optional[str]) -> None: k = 2 if take == "new" else 1 got = step_presented(rel["dirs"][k - 1]) rel.update(state="confirmed", k=k, sha=got.get("sha"), prs=got.get("prs") or []) - self.pause("confirmed", n=rel["n"], k=k, plan=str(Path(rel["dirs"][k - 1]) / "release.json"), spawn=helper_spawn(self.name, rel["n"], k)) + self.pause( + "confirmed", n=rel["n"], k=k, plan=str(Path(rel["dirs"][k - 1]) / "release.json"), spawn=helper_spawn(self.name, rel["n"], k) + ) def settle(self, rel: dict, msg: dict) -> None: result = msg.get("result") @@ -283,7 +292,15 @@ def settle(self, rel: dict, msg: dict) -> None: hit = set(pr.answerable(self.pending, rel["prs"])) if result == "released": rel.update(state="released", version=msg.get("version") or rel.get("version")) - durable.set_event(f"range-{rel['n']}", {"sha": rel.get("sha"), "version": rel["version"], "prs": rel["prs"], "lanes": sorted({r["lane"] for r in self.pending if r["id"] in hit})}) + durable.set_event( + f"range-{rel['n']}", + { + "sha": rel.get("sha"), + "version": rel["version"], + "prs": rel["prs"], + "lanes": sorted({r["lane"] for r in self.pending if r["id"] in hit}), + }, + ) for r in [r for r in self.pending if r["id"] in hit]: self.answer(r["id"], "released", rel["version"], rel["n"]) else: @@ -298,7 +315,9 @@ def settle(self, rel: dict, msg: dict) -> None: def helper_spawn(name: str, n: int, k: int) -> str: """一時レーンを起こす 1 行(`metrics.spawn`)。""" - first = f"/goal /ndf:development-workflow --parallel-helper {name} {n} {k} -- 終わり: [ndf-parallel] helper-done {name} の行を出したとき" + first = ( + f"/goal /ndf:development-workflow --parallel-helper {name} {n} {k} -- 終わり: [ndf-parallel] helper-done {name} の行を出したとき" + ) return f"python3 {RELAY} spawn --name {pr.helper_name(name, n)} --role helper --plan {name} --json -- '{first}'" @@ -347,7 +366,10 @@ def parallel_workflow(main: str, name: str) -> dict: def _plan_view(run: _Run) -> pp.ParallelPlan: """起動の判定に使う計画の形(耐久ワークフローの入力から作る。ファイルを読まない)。""" - lanes = [{"name": n, "input": {"kind": "loop", "loop": n, "args": ["--milestones", n]}, "plans": 1, "touches": [], "after": run.after[n]} for n in run.order] + lanes = [ + {"name": n, "input": {"kind": "loop", "loop": n, "args": ["--milestones", n]}, "plans": 1, "touches": [], "after": run.after[n]} + for n in run.order + ] return pp.ParallelPlan.model_construct(version=1, name=run.name, rules=[], lanes=[pp.Lane.model_construct(**ln) for ln in lanes]) diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index a93348871..5da53a551 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -113,7 +113,13 @@ def _manual_or_none(main: Path, plan: pp.ParallelPlan) -> None: why = "統括役が居ない" if why: lines = pp.manual_commands(plan) - out("stopped", f"{why}ため、レーンを起動しない。手で起動する 1 行を items に並べた", [{"lane": ln.name, "command": c} for ln, c in zip(plan.lanes, lines)], 3, manual=True) + out( + "stopped", + f"{why}ため、レーンを起動しない。手で起動する 1 行を items に並べた", + [{"lane": ln.name, "command": c} for ln, c in zip(plan.lanes, lines)], + 3, + manual=True, + ) def _wait_line(name: str) -> str: @@ -172,7 +178,13 @@ def cmd_wait(a) -> None: out("stopped", f"並列の計画 {a.name} の耐久の記録が無い", code=2) got = durable.wait_from(pl.KIND, pl.identity(main, a.name), pl.workflow_id(a.name), ["pause", "questions"], a.after, a.timeout) if got.kind == "timeout": - out("stopped", f"{a.timeout:.0f} 秒の上限に達した。同じ wait を打ち直す", code=3, seq=a.after, next=_wait_line(a.name) + f" --after {a.after}") + out( + "stopped", + f"{a.timeout:.0f} 秒の上限に達した。同じ wait を打ち直す", + code=3, + seq=a.after, + next=_wait_line(a.name) + f" --after {a.after}", + ) if got.kind == "event": ev = dict(got.value) key = ev.pop("key") @@ -205,7 +217,13 @@ def cmd_present(a) -> None: except OSError as exc: out("stopped", f"読めない: {exc}", code=2) Path(a.out).with_name("approved.txt").write_text(got["sha"] + "\n", encoding="utf-8") - out("ok", f"提示 {a.n}-{a.k}: {got['sha'][:12]} まで(Pull Request {len(got['prs'])} 本)", [{"lane": n, **r} for n, r in got["lanes"].items()], sha=got["sha"], orphans=got["orphans"]) + out( + "ok", + f"提示 {a.n}-{a.k}: {got['sha'][:12]} まで(Pull Request {len(got['prs'])} 本)", + [{"lane": n, **r} for n, r in got["lanes"].items()], + sha=got["sha"], + orphans=got["orphans"], + ) def _requests_for(main: Path, name: str) -> list[dict]: @@ -249,7 +267,9 @@ def cmd_settle(a) -> None: res = "released" if results and all(r == "完了" for r in results) else "failed" version = done.get("version") or (done.get("metrics") or {}).get("version") wid = pl.workflow_id(a.name) - _resume(main, a.name, {"n": a.n, "phase": "settle", "result": res, "version": version, "reason": a.reason}, f"{wid}:resume:settle:{a.n}") + _resume( + main, a.name, {"n": a.n, "phase": "settle", "result": res, "version": version, "reason": a.reason}, f"{wid}:resume:settle:{a.n}" + ) out("ok", f"配布 {a.n} の結果 {res} を送った", result=res, version=version) @@ -300,7 +320,16 @@ def cmd_await(a) -> None: res = ans.get("result") code = {"released": 0, "rejected": 10}.get(res, 1) status = {0: "ok", 10: "gate"}.get(code, "stopped") - out(status, f"配布の依頼 {sent['id']} の答え: {res}", code=code, result=res, version=ans.get("version"), release=ans.get("release"), reason=ans.get("reason"), deferred=dfr) + out( + status, + f"配布の依頼 {sent['id']} の答え: {res}", + code=code, + result=res, + version=ans.get("version"), + release=ans.get("release"), + reason=ans.get("reason"), + deferred=dfr, + ) def parser() -> argparse.ArgumentParser: diff --git a/plugins/ndf/scripts/relay_lib/hub_client.py b/plugins/ndf/scripts/relay_lib/hub_client.py index 1cb502343..a8d566912 100644 --- a/plugins/ndf/scripts/relay_lib/hub_client.py +++ b/plugins/ndf/scripts/relay_lib/hub_client.py @@ -247,9 +247,7 @@ def _iso(epoch: float | None) -> str | None: return _dt.datetime.fromtimestamp(epoch, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") -def _ls_item( - name: str, w: hb.Wrapper | None, session: dict | None, pending: bool, hub_dir: str | None, role: str | None = None -) -> dict: +def _ls_item(name: str, w: hb.Wrapper | None, session: dict | None, pending: bool, hub_dir: str | None, role: str | None = None) -> dict: running = bool(w and w.running) or pending waiting = bool(w and w.running and w.question) state = "awaiting_approval" if waiting else "running" if running else "stopped" diff --git a/plugins/ndf/scripts/supervise.py b/plugins/ndf/scripts/supervise.py index fd37f03d4..fc6b70e6b 100755 --- a/plugins/ndf/scripts/supervise.py +++ b/plugins/ndf/scripts/supervise.py @@ -286,7 +286,6 @@ def _cmd_example(ap: argparse.ArgumentParser, a: argparse.Namespace) -> int: } - def default_max(cwd: str | None = None) -> int: """`queue` の `--max` を省いたときの本数。並列の計画のレーンならレーンのプランの本数、そうでなければ 3(#2436)。""" import decl @@ -300,6 +299,7 @@ def default_max(cwd: str | None = None) -> int: n = None return n or 3 + def main() -> int: ap = argparse.ArgumentParser( description=__doc__.splitlines()[0], epilog=MAIN_EPILOG, formatter_class=argparse.RawDescriptionHelpFormatter diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index 80825559f..4d3ff5930 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -117,7 +117,11 @@ def fast_sprint_plans(a) -> list[dict]: return waves + route_waves(a, repo, first, mvv=a.state) # 開発版のステージは置かない(#1336 の決定 12) waves += [ {"name": "開発版", "plans": {"release": plan_mvv_release(a, repo, a.version, "dev")}, "then_of": first}, - {"name": "本番", "plans": prod_plans(a, repo, lambda: plan_mvv_release(a, repo, prod_version(a.version), "prod")), "then_of": first}, + { + "name": "本番", + "plans": prod_plans(a, repo, lambda: plan_mvv_release(a, repo, prod_version(a.version), "prod")), + "then_of": first, + }, ] return waves @@ -153,7 +157,11 @@ def auto_sprint_plans(a) -> list[dict]: prs = ["{queue_pr:check}"] # 出す版の PR は検査のステージのスプリントの PR(関門 2 の判定のコメントの宛先) return waves + [ {"name": "開発版", "plans": {"release": plan_mvv_release(a, repo, a.version, "dev", prs=prs)}, "then_of": first}, - {"name": "本番", "plans": prod_plans(a, repo, lambda: plan_mvv_release(a, repo, prod_version(a.version), "prod", prs=prs), prs), "then_of": first}, + { + "name": "本番", + "plans": prod_plans(a, repo, lambda: plan_mvv_release(a, repo, prod_version(a.version), "prod", prs=prs), prs), + "then_of": first, + }, ] diff --git a/plugins/ndf/scripts/supervise_lib/sprint_waves.py b/plugins/ndf/scripts/supervise_lib/sprint_waves.py index b62824e85..fdfda6106 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_waves.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_waves.py @@ -13,7 +13,17 @@ from gate_answer import GATE_1 from supervise_lib.decl import decl_fields -from supervise_lib.paths import DESIGN_READY, GLOSSARY_PY, MVV_PY, PARALLEL_PY, PUSH_DESIGN, SELF, SPEC_COPY_PY, SYNC_DESIGN_BODY, WORKTREE_SETUP +from supervise_lib.paths import ( + DESIGN_READY, + GLOSSARY_PY, + MVV_PY, + PARALLEL_PY, + PUSH_DESIGN, + SELF, + SPEC_COPY_PY, + SYNC_DESIGN_BODY, + WORKTREE_SETUP, +) from supervise_lib.procedures import DESIGN_RESULTS, pr_step, rebuild_step, requirements_path, sprint_materials, sprint_out, with_record from supervise_lib.release_templates import DEV_SERIES, MVV_NOTE, advise_steps, plan_release, release_names from supervise_lib.plan import sprint_state_path @@ -522,9 +532,7 @@ def plan_release_request(a, repo: str, plan_name: str, lane: str, prs: list[str] "作業場所": repo, "規則": "", "並列の計画": plan_name, - "steps": [ - {"id": "parallel-request", "type": "run", "stage": "配布", "timeout": 300, "cmd": cmd, "next": "end"} - ], + "steps": [{"id": "parallel-request", "type": "run", "stage": "配布", "timeout": 300, "cmd": cmd, "next": "end"}], } ) @@ -542,10 +550,34 @@ def plan_parallel_release(main, name: str, d, version: str) -> tuple[dict, dict] d = Path(d) seq, k = d.parent.name, d.name ns = argparse.Namespace( - worktree=repo, repo=repo, base=None, production_branch=None, release=None, issue=[], mode="standard", - version=version, prev_tag=None, mvv=None, advise=None, channel="dev", prs=[], prs_from_queue=False, - test_cmd=None, test_kind=None, test_paths=None, test_all=None, no_reports=None, sync_checks=None, format=None, - glossary_ready=None, strategy=None, test_limits=None, template="sprint", level=None, series=None, branch=None, + worktree=repo, + repo=repo, + base=None, + production_branch=None, + release=None, + issue=[], + mode="standard", + version=version, + prev_tag=None, + mvv=None, + advise=None, + channel="dev", + prs=[], + prs_from_queue=False, + test_cmd=None, + test_kind=None, + test_paths=None, + test_all=None, + no_reports=None, + sync_checks=None, + format=None, + glossary_ready=None, + strategy=None, + test_limits=None, + template="sprint", + level=None, + series=None, + branch=None, ) apply_decls(ns) plugin = (ns.release or {}).get("plugin", "ndf") diff --git a/plugins/ndf/scripts/tests/test_parallel_lane.py b/plugins/ndf/scripts/tests/test_parallel_lane.py index a1ed17bb7..ab369a03d 100644 --- a/plugins/ndf/scripts/tests/test_parallel_lane.py +++ b/plugins/ndf/scripts/tests/test_parallel_lane.py @@ -51,6 +51,8 @@ def test_non_lane_sprint_keeps_prod_plan(monkeypatch): monkeypatch.setattr(sprint, "parallel_lane", lambda repo: None) assert list(sprint.prod_plans(None, "/r", lambda: {"x": 1})) == ["release-prod"] monkeypatch.setattr(sprint, "parallel_lane", lambda repo: ("p1", "la")) - monkeypatch.setattr(sprint, "plan_release_request", lambda a, repo, plan, lane, prs=None: {"フェーズ": "配布の依頼", "plan": plan, "lane": lane}) + monkeypatch.setattr( + sprint, "plan_release_request", lambda a, repo, plan, lane, prs=None: {"フェーズ": "配布の依頼", "plan": plan, "lane": lane} + ) got = sprint.prod_plans(None, "/r", lambda: {"x": 1}) assert got == {"release-request": {"フェーズ": "配布の依頼", "plan": "p1", "lane": "la"}} diff --git a/plugins/ndf/scripts/tests/test_parallel_plan.py b/plugins/ndf/scripts/tests/test_parallel_plan.py index ca159dc8e..8294e9360 100644 --- a/plugins/ndf/scripts/tests/test_parallel_plan.py +++ b/plugins/ndf/scripts/tests/test_parallel_plan.py @@ -22,7 +22,12 @@ "name": "p1", "rules": ["取り込みの衝突は base-sync で解く"], "lanes": [ - {"name": "lroot", "input": {"kind": "loop", "loop": "lroot", "args": ["--milestones", "26"]}, "plans": 2, "touches": ["plugins/ndf/"]}, + { + "name": "lroot", + "input": {"kind": "loop", "loop": "lroot", "args": ["--milestones", "26"]}, + "plans": 2, + "touches": ["plugins/ndf/"], + }, {"name": "lflow", "input": {"kind": "loop", "loop": "lflow", "args": ["--milestones", "23"]}, "plans": 2, "touches": ["docs/"]}, {"name": "lfac", "input": {"kind": "sprint", "issues": [2400]}, "plans": 2, "touches": ["factory/"], "after": ["lflow"]}, ], @@ -55,7 +60,7 @@ def test_valid_plan_passes(): (variant(x=lambda d: d["lanes"][0].__setitem__("plans", 3)), "I2"), (variant(x=lambda d: d["lanes"].append({**d["lanes"][0], "name": "l4", "plans": 0})), "form"), (variant(x=lambda d: d["lanes"][2].__setitem__("after", ["nope"])), "I3"), - (variant(x=lambda d: (d["lanes"][1].__setitem__("after", ["lfac"]))), "I3"), + (variant(x=lambda d: d["lanes"][1].__setitem__("after", ["lfac"])), "I3"), (variant(x=lambda d: d["lanes"][1]["input"].__setitem__("args", [])), "I19"), (variant(x=lambda d: d["lanes"][1]["input"].__setitem__("args", ["--milestones", "26"])), "I19"), ], diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index b19993f62..cbcad2bcd 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -124,7 +124,14 @@ def test_flow_spawns_in_order_releases_without_waiting_and_finishes(env, tmp_pat # 先頭が同じなら確定(I14) durable.send_to(pl.KIND, pl.identity(main, "p1"), pl.workflow_id("p1"), {"n": 1, "phase": "confirm"}, "resume", "c1") until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "confirmed") - durable.send_to(pl.KIND, pl.identity(main, "p1"), pl.workflow_id("p1"), {"n": 1, "phase": "settle", "result": "released", "version": "1.2.3"}, "resume", "s1") + durable.send_to( + pl.KIND, + pl.identity(main, "p1"), + pl.workflow_id("p1"), + {"n": 1, "phase": "settle", "result": "released", "version": "1.2.3"}, + "resume", + "s1", + ) ans, _ = until(lambda: pl.read_answer(main, "p1", rid)[0] and pl.read_answer(main, "p1", rid)) assert ans["result"] == "released" and ans["version"] == "1.2.3" assert event(main, "p1", "range-1")["prs"] == [10, 11] From 736bcfb66650d21fd5988b5fab471482af0b948e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 04:03:23 +0000 Subject: [PATCH 06/70] =?UTF-8?q?Refactor:=20=E4=B8=A6=E5=88=97=E3=81=AE?= =?UTF-8?q?=E9=85=8D=E5=B8=83=E3=81=AE=E3=83=97=E3=83=A9=E3=83=B3=E3=81=AE?= =?UTF-8?q?=E7=B5=84=E3=81=BF=E7=AB=8B=E3=81=A6=E3=82=92=20parallel=5Frele?= =?UTF-8?q?ase=20=E3=81=B8=E7=A7=BB=E3=81=97=E3=80=81500=20=E8=A1=8C?= =?UTF-8?q?=E3=81=AE=E4=B8=8A=E9=99=90=E3=81=AB=E5=8F=8E=E3=82=81=E3=82=8B?= =?UTF-8?q?=EF=BC=88#2436=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_release.py | 158 +++++++++++++++++- .../supervise_lib/release_templates.py | 33 ---- plugins/ndf/scripts/supervise_lib/sprint.py | 3 +- .../ndf/scripts/supervise_lib/sprint_waves.py | 119 +------------ .../ndf/scripts/tests/test_parallel_lane.py | 4 +- plugins/ndf/scripts/tests/test_relay_hub.py | 2 +- 6 files changed, 164 insertions(+), 155 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_release.py b/plugins/ndf/scripts/lib/parallel_release.py index 80e503c27..731eab73d 100644 --- a/plugins/ndf/scripts/lib/parallel_release.py +++ b/plugins/ndf/scripts/lib/parallel_release.py @@ -7,7 +7,9 @@ from __future__ import annotations +import argparse import json +import shlex import subprocess from pathlib import Path from typing import Callable, Iterable, Optional @@ -143,7 +145,7 @@ def build_plans(main: Path, name: str, seq: int, k: int, version: str, builder: d = release_dir(main, name, seq, k) d.mkdir(parents=True, exist_ok=True) if builder is None: - from supervise_lib.sprint_waves import plan_parallel_release as builder # type: ignore[no-redef] + builder = plan_parallel_release present_plan, release_plan = builder(main, name, d, version) (d / "present.json").write_text(json.dumps(present_plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") (d / "release.json").write_text(json.dumps(release_plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") @@ -151,3 +153,157 @@ def build_plans(main: Path, name: str, seq: int, k: int, version: str, builder: if not answer.exists(): answer.write_text(json.dumps({"gates": []}) + "\n", encoding="utf-8") return d + + +# ---- プランの組み立て(supervise_lib の部品を使う。supervise_lib は呼ぶときに読む) ---- + + +def present_gate_steps(state: str, repo: str | None, approval: str, prs: str, mode: str, facts_from: str, answer_from: str) -> list[dict]: + """まとめた配布(#2436)の提示のプランの承認ゲート 2: MVV 判定(mvv・note)→ approve の順に置く。 + + 今の本番のプランでは MVV 判定と approve が排他だが、提示のプランは両方を並べる。MVV 判定が通せば承認の記録が + 既にあるため approve は答えの記録を読まずに通り、関門(10)なら提示のプランは承認ゲートで終わる。確定の後は + 一時レーンが答えを `answer_from` へ書いて `--from approve` で続ける。""" + from gate_answer import GATE_2 + from supervise_lib.paths import MVV_PY, STEPS_PY + from supervise_lib.release_templates import MVV_NOTE, material_path, mvv_gate_steps + from supervise_lib.verify_steps import handoff_step, reject_step + + material = material_path(repo, approval) + st = shlex.quote(str(Path(state).resolve())) + steps = mvv_gate_steps( + f"{MVV_PY} check --sprint {st} --gate release --material {shlex.quote(material)} --pr {prs} --mode {mode}" + + (f" --root {shlex.quote(repo)}" if repo else "") + + f" --note {MVV_NOTE}", + f"sh -c 'for p in {prs}; do gh pr comment \"$p\" --body-file {MVV_NOTE} || exit 1; done'", + "approve", + ) + steps.append( + { + "id": "approve", + "type": "run", + "stage": "配布", + "timeout": 120, + **({"cwd": repo} if repo else {}), + "cmd": f"{STEPS_PY} approve --approval {approval} --facts-from {shlex.quote(facts_from)}" + f" --answer-from {shlex.quote(answer_from)} --by user", + "next": "end", + "gate_next": "end", + } + ) + steps.append(handoff_step(str(Path(state).resolve()), GATE_2, "判定のコメント")) + steps.append(reject_step(str(Path(state).resolve()), GATE_2, "mvv", root=repo or ".")) + return steps + + +RELEASE_REQUEST_PHASE = "配布の依頼" + + +def plan_release_request(a, repo: str, plan_name: str, lane: str, prs: list[str] | None = None) -> dict: + """並列の計画のレーンの本番のステージ(#2436 の F6)。本番を流さず、オーガナイザーへ配布の依頼を送る。 + + ステップは `parallel-request` の 1 つで、依頼を送れば 10(承認ゲート)で終わり、自分の Pull Request が + すでに `main` にあれば送らずに 0 で終わる(I15)。`prs` は出す版の Pull Request(`{queue_pr:<名>}` でもよい)。""" + from supervise_lib.paths import PARALLEL_PY + from supervise_lib.procedures import with_record + + issues = " ".join(str(n) for n in (a.issue or [])) + pr_args = " ".join(prs or []) + cmd = ( + f"{PARALLEL_PY} request {shlex.quote(plan_name)} --lane {shlex.quote(lane)}" + + (f" --prs {pr_args}" if pr_args else "") + + (f" --issues {issues}" if issues else "") + + " --state {state_dir} --out {state_dir}/work/request.txt" + ) + return with_record( + { + "フェーズ": RELEASE_REQUEST_PHASE, + "課題": a.issue, + "モード": a.mode, + "作業場所": repo, + "規則": "", + "並列の計画": plan_name, + "steps": [{"id": "parallel-request", "type": "run", "stage": "配布", "timeout": 300, "cmd": cmd, "next": "end"}], + } + ) + + +def plan_parallel_release(main, name: str, d, version: str) -> tuple[dict, dict]: + """まとめた配布(#2436)の 1 つの提示の (提示のプラン, 本番のプラン)。`d` は `releases/<連番>/<提示>/`。 + + 提示のプランは開発版の承認資料のステップ(approval-facts・承認資料の説明の欄・他のプラグインの上げ幅)を版上げ無しで + 流し、`parallel.py present` でレーンの内訳を足し、最後に承認ゲート 2(MVV 判定 → approve)を置く。本番のプランは + 承認ゲート 2 を持たず、確定した提示のプランの承認したコミット(`{queue_approved:present}`)から `release/v<版>` を切る。""" + from supervise_lib.decl import apply_decls + from supervise_lib.paths import PARALLEL_PY + from supervise_lib.procedures import with_record + from supervise_lib.release_templates import _dev_approval_steps + from supervise_lib.sprint_waves import plan_mvv_release + + repo = str(main) + d = Path(d) + seq, k = d.parent.name, d.name + ns = argparse.Namespace( + worktree=repo, + repo=repo, + base=None, + production_branch=None, + release=None, + issue=[], + mode="standard", + version=version, + prev_tag=None, + mvv=None, + advise=None, + channel="dev", + prs=[], + prs_from_queue=False, + test_cmd=None, + test_kind=None, + test_paths=None, + test_all=None, + no_reports=None, + sync_checks=None, + format=None, + glossary_ready=None, + strategy=None, + test_limits=None, + template="sprint", + level=None, + series=None, + branch=None, + ) + apply_decls(ns) + plugin = (ns.release or {}).get("plugin", "ndf") + approval = f"issues/approval-{plugin}-v{version}-parallel-{seq}-{k}.md" + state = d / "answer.json" + present_state = d / "present" # 提示のプランの状態ディレクトリ(`state_dir_of` の置き場と同じ) + facts = _dev_approval_steps(ns, repo, approval, "0", ",".join((ns.release or {}).get("runtimes") or [])) + facts[-1]["next"] = "present" + first = d.parent / "1" / "presented.json" + present = { + "id": "present", + "type": "run", + "stage": "配布", + "timeout": 300, + "cwd": repo, + "cmd": f"{PARALLEL_PY} present {shlex.quote(name)} {seq} {k} --approval {approval} --out {{state_dir}}/work/prs.txt" + + (f" --first {shlex.quote(str(first))}" if k != "1" else ""), + "next": "mvv", + } + gate = present_gate_steps(str(state), repo, approval, "$(cat {state_dir}/work/prs.txt)", "standard", str(present_state), str(state)) + present_plan = with_record( + { + "フェーズ": "配布(提示)", + "課題": [], + "モード": "standard", + "作業場所": repo, + "規則": "", + "並列の計画": name, + "steps": [*facts, present, *gate], + } + ) + release_plan = plan_mvv_release(ns, repo, version, "prod", prs=["{queue_prs:present}"]) + release_plan["起点"] = "{queue_approved:present}" + release_plan["並列の計画"] = name + return present_plan, release_plan diff --git a/plugins/ndf/scripts/supervise_lib/release_templates.py b/plugins/ndf/scripts/supervise_lib/release_templates.py index 7d0417fcd..f3a7c283e 100644 --- a/plugins/ndf/scripts/supervise_lib/release_templates.py +++ b/plugins/ndf/scripts/supervise_lib/release_templates.py @@ -485,36 +485,3 @@ def material_path(repo, approval): # 配布の形(release の form-<形>.md)ごとの雛形。無い形は /ndf:release で配る RELEASE_FORMS = {"package-plugin": plan_release_package_plugin} - - -def present_gate_steps(state: str, repo: str | None, approval: str, prs: str, mode: str, facts_from: str, answer_from: str) -> list[dict]: - """まとめた配布(#2436)の提示のプランの承認ゲート 2: MVV 判定(mvv・note)→ approve の順に置く。 - - 今の本番のプランでは MVV 判定と approve が排他だが、提示のプランは両方を並べる。MVV 判定が通せば承認の記録が - 既にあるため approve は答えの記録を読まずに通り、関門(10)なら提示のプランは承認ゲートで終わる。確定の後は - 一時レーンが答えを `answer_from` へ書いて `--from approve` で続ける。""" - material = material_path(repo, approval) - st = shlex.quote(str(Path(state).resolve())) - steps = mvv_gate_steps( - f"{MVV_PY} check --sprint {st} --gate release --material {shlex.quote(material)} --pr {prs} --mode {mode}" - + (f" --root {shlex.quote(repo)}" if repo else "") - + f" --note {MVV_NOTE}", - f"sh -c 'for p in {prs}; do gh pr comment \"$p\" --body-file {MVV_NOTE} || exit 1; done'", - "approve", - ) - steps.append( - { - "id": "approve", - "type": "run", - "stage": "配布", - "timeout": 120, - **({"cwd": repo} if repo else {}), - "cmd": f"{STEPS_PY} approve --approval {approval} --facts-from {shlex.quote(facts_from)}" - f" --answer-from {shlex.quote(answer_from)} --by user", - "next": "end", - "gate_next": "end", - } - ) - steps.append(handoff_step(str(Path(state).resolve()), GATE_2, "判定のコメント")) - steps.append(reject_step(str(Path(state).resolve()), GATE_2, "mvv", root=repo or ".")) - return steps diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index 4d3ff5930..66b91c363 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -14,7 +14,6 @@ from supervise_lib.decl import decl_roots, with_decls from supervise_lib.design_merge import plan_design_merge from supervise_lib.sprint_waves import ( - plan_release_request, sprint_branch, plan_fast_check, plan_fast_impl, @@ -65,6 +64,8 @@ def prod_plans(a, repo: str, build, prs: list[str] | None = None) -> dict: """本番のステージのプラン。並列の計画のレーンなら配布の依頼のプラン、そうでなければ `build()` の本番のプラン。""" lane = parallel_lane(repo) if lane: + from parallel_release import plan_release_request + return {"release-request": plan_release_request(a, repo, lane[0], lane[1], prs)} return {"release-prod": build()} diff --git a/plugins/ndf/scripts/supervise_lib/sprint_waves.py b/plugins/ndf/scripts/supervise_lib/sprint_waves.py index fdfda6106..991e3218b 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_waves.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_waves.py @@ -13,17 +13,7 @@ from gate_answer import GATE_1 from supervise_lib.decl import decl_fields -from supervise_lib.paths import ( - DESIGN_READY, - GLOSSARY_PY, - MVV_PY, - PARALLEL_PY, - PUSH_DESIGN, - SELF, - SPEC_COPY_PY, - SYNC_DESIGN_BODY, - WORKTREE_SETUP, -) +from supervise_lib.paths import DESIGN_READY, GLOSSARY_PY, MVV_PY, PUSH_DESIGN, SELF, SPEC_COPY_PY, SYNC_DESIGN_BODY, WORKTREE_SETUP from supervise_lib.procedures import DESIGN_RESULTS, pr_step, rebuild_step, requirements_path, sprint_materials, sprint_out, with_record from supervise_lib.release_templates import DEV_SERIES, MVV_NOTE, advise_steps, plan_release, release_names from supervise_lib.plan import sprint_state_path @@ -506,110 +496,3 @@ def plan_mvv_release(a, repo: str, version: str | None, channel: str, condition: if condition: plan["実行の条件"] = condition return plan - - -RELEASE_REQUEST_PHASE = "配布の依頼" - - -def plan_release_request(a, repo: str, plan_name: str, lane: str, prs: list[str] | None = None) -> dict: - """並列の計画のレーンの本番のステージ(#2436 の F6)。本番を流さず、オーガナイザーへ配布の依頼を送る。 - - ステップは `parallel-request` の 1 つで、依頼を送れば 10(承認ゲート)で終わり、自分の Pull Request が - すでに `main` にあれば送らずに 0 で終わる(I15)。`prs` は出す版の Pull Request(`{queue_pr:<名>}` でもよい)。""" - issues = " ".join(str(n) for n in (a.issue or [])) - pr_args = " ".join(prs or []) - cmd = ( - f"{PARALLEL_PY} request {shlex.quote(plan_name)} --lane {shlex.quote(lane)}" - + (f" --prs {pr_args}" if pr_args else "") - + (f" --issues {issues}" if issues else "") - + " --state {state_dir} --out {state_dir}/work/request.txt" - ) - return with_record( - { - "フェーズ": RELEASE_REQUEST_PHASE, - "課題": a.issue, - "モード": a.mode, - "作業場所": repo, - "規則": "", - "並列の計画": plan_name, - "steps": [{"id": "parallel-request", "type": "run", "stage": "配布", "timeout": 300, "cmd": cmd, "next": "end"}], - } - ) - - -def plan_parallel_release(main, name: str, d, version: str) -> tuple[dict, dict]: - """まとめた配布(#2436)の 1 つの提示の (提示のプラン, 本番のプラン)。`d` は `releases/<連番>/<提示>/`。 - - 提示のプランは開発版の承認資料のステップ(approval-facts・承認資料の説明の欄・他のプラグインの上げ幅)を版上げ無しで - 流し、`parallel.py present` でレーンの内訳を足し、最後に承認ゲート 2(MVV 判定 → approve)を置く。本番のプランは - 承認ゲート 2 を持たず、確定した提示のプランの承認したコミット(`{queue_approved:present}`)から `release/v<版>` を切る。""" - from supervise_lib.decl import apply_decls - from supervise_lib.release_templates import _dev_approval_steps, present_gate_steps - - repo = str(main) - d = Path(d) - seq, k = d.parent.name, d.name - ns = argparse.Namespace( - worktree=repo, - repo=repo, - base=None, - production_branch=None, - release=None, - issue=[], - mode="standard", - version=version, - prev_tag=None, - mvv=None, - advise=None, - channel="dev", - prs=[], - prs_from_queue=False, - test_cmd=None, - test_kind=None, - test_paths=None, - test_all=None, - no_reports=None, - sync_checks=None, - format=None, - glossary_ready=None, - strategy=None, - test_limits=None, - template="sprint", - level=None, - series=None, - branch=None, - ) - apply_decls(ns) - plugin = (ns.release or {}).get("plugin", "ndf") - approval = f"issues/approval-{plugin}-v{version}-parallel-{seq}-{k}.md" - state = d / "answer.json" - present_state = d / "present" # 提示のプランの状態ディレクトリ(`state_dir_of` の置き場と同じ) - facts = _dev_approval_steps(ns, repo, approval, "0", ",".join((ns.release or {}).get("runtimes") or [])) - facts[-1]["next"] = "present" - first = d.parent / "1" / "presented.json" - present = { - "id": "present", - "type": "run", - "stage": "配布", - "timeout": 300, - "cwd": repo, - "cmd": f"{PARALLEL_PY} present {shlex.quote(name)} {seq} {k} --approval {approval} --out {{state_dir}}/work/prs.txt" - + (f" --first {shlex.quote(str(first))}" if k != "1" else ""), - "next": "mvv", - } - gate = present_gate_steps(str(state), repo, approval, "$(cat {state_dir}/work/prs.txt)", "standard", str(present_state), str(state)) - present_plan = with_record( - { - "フェーズ": "配布(提示)", - "課題": [], - "モード": "standard", - "作業場所": repo, - "規則": "", - "並列の計画": name, - "steps": [*facts, present, *gate], - } - ) - release_plan = plan_mvv_release(ns, repo, version, "prod", prs=["{queue_prs:present}"]) - release_plan["起点"] = "{queue_approved:present}" - release_plan["並列の計画"] = name - return present_plan, release_plan diff --git a/plugins/ndf/scripts/tests/test_parallel_lane.py b/plugins/ndf/scripts/tests/test_parallel_lane.py index ab369a03d..7184afa75 100644 --- a/plugins/ndf/scripts/tests/test_parallel_lane.py +++ b/plugins/ndf/scripts/tests/test_parallel_lane.py @@ -51,8 +51,10 @@ def test_non_lane_sprint_keeps_prod_plan(monkeypatch): monkeypatch.setattr(sprint, "parallel_lane", lambda repo: None) assert list(sprint.prod_plans(None, "/r", lambda: {"x": 1})) == ["release-prod"] monkeypatch.setattr(sprint, "parallel_lane", lambda repo: ("p1", "la")) + import parallel_release + monkeypatch.setattr( - sprint, "plan_release_request", lambda a, repo, plan, lane, prs=None: {"フェーズ": "配布の依頼", "plan": plan, "lane": lane} + parallel_release, "plan_release_request", lambda a, repo, plan, lane, prs=None: {"フェーズ": "配布の依頼", "plan": plan, "lane": lane} ) got = sprint.prod_plans(None, "/r", lambda: {"x": 1}) assert got == {"release-request": {"フェーズ": "配布の依頼", "plan": "p1", "lane": "la"}} diff --git a/plugins/ndf/scripts/tests/test_relay_hub.py b/plugins/ndf/scripts/tests/test_relay_hub.py index bd0a293c3..b7489015d 100644 --- a/plugins/ndf/scripts/tests/test_relay_hub.py +++ b/plugins/ndf/scripts/tests/test_relay_hub.py @@ -250,7 +250,7 @@ def test_ls_json_lists_state_from_files(dirs, capsys): for it in res["items"]: assert set(it) == {"name", "state", "awaiting_approval", "sections", "cost_usd", "at", "account", "ndf_version", "attached", "hub", "role"} assert it["cost_usd"] is None and "最初" not in json.dumps(it, ensure_ascii=False) - assert res["metrics"] == {"running": 2, "awaiting_approval": 1, "stopped": 0, "limit": 3} + assert res["metrics"] == {"running": 2, "awaiting_approval": 1, "stopped": 0, "limit": 3, "hub": False, "tmux": False} assert hc.cmd_ls([]) == 0 table = capsys.readouterr().out.splitlines() assert table[0].split()[0] == "名前" and len(table) == 4 From c556c0a1d255b4342627812ecfcc01674ffcb8a3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 04:03:47 +0000 Subject: [PATCH 07/70] =?UTF-8?q?Test:=20=E9=85=8D=E5=B8=83=E3=81=AE?= =?UTF-8?q?=E4=BE=9D=E9=A0=BC=E3=81=AE=E3=83=97=E3=83=A9=E3=83=B3=E3=81=A8?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E3=81=AE=E6=89=BF=E8=AA=8D=E3=82=B2=E3=83=BC?= =?UTF-8?q?=E3=83=88=202=20=E3=81=AE=E4=B8=A6=E3=81=B3=E3=82=92=E7=B8=9B?= =?UTF-8?q?=E3=82=8B=EF=BC=88#2436=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../ndf/scripts/tests/test_parallel_lane.py | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/plugins/ndf/scripts/tests/test_parallel_lane.py b/plugins/ndf/scripts/tests/test_parallel_lane.py index 7184afa75..89af781ec 100644 --- a/plugins/ndf/scripts/tests/test_parallel_lane.py +++ b/plugins/ndf/scripts/tests/test_parallel_lane.py @@ -54,7 +54,29 @@ def test_non_lane_sprint_keeps_prod_plan(monkeypatch): import parallel_release monkeypatch.setattr( - parallel_release, "plan_release_request", lambda a, repo, plan, lane, prs=None: {"フェーズ": "配布の依頼", "plan": plan, "lane": lane} + parallel_release, + "plan_release_request", + lambda a, repo, plan, lane, prs=None: {"フェーズ": "配布の依頼", "plan": plan, "lane": lane}, ) got = sprint.prod_plans(None, "/r", lambda: {"x": 1}) assert got == {"release-request": {"フェーズ": "配布の依頼", "plan": "p1", "lane": "la"}} + + +def test_release_request_plan_has_one_gate_step(): + import argparse + + import supervise_lib # noqa: F401 lib/ と supervise_lib を読めるようにする + import parallel_release + + plan = parallel_release.plan_release_request(argparse.Namespace(issue=[2436], mode="standard"), "/r", "p1", "la", ["{queue_pr:check}"]) + assert plan["フェーズ"] == "配布の依頼" and [s["id"] for s in plan["steps"]] == ["parallel-request"] + assert "request p1 --lane la --prs {queue_pr:check} --issues 2436" in plan["steps"][0]["cmd"] + + +def test_present_gate_puts_approve_after_mvv(tmp_path): + import supervise_lib # noqa: F401 + import parallel_release + + steps = parallel_release.present_gate_steps(str(tmp_path / "a.json"), "/r", "issues/x.md", "1 2", "standard", "/f", "/a") + ids = [s["id"] for s in steps] + assert ids[:3] == ["mvv", "note", "approve"] and steps[1]["next"] == "approve" and steps[2]["next"] == "end" From 3ee692ebff1879ad93fa26b88dc8ec0238a2cff8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 04:11:34 +0000 Subject: [PATCH 08/70] =?UTF-8?q?Update:=20=E5=AE=A3=E8=A8=80=E3=81=AE?= =?UTF-8?q?=E6=95=B4=E5=BD=A2=E3=82=92=E5=BD=93=E3=81=A6=E3=82=8B=EF=BC=88?= =?UTF-8?q?lint-fix=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/ndf/scripts/tests/test_relay_hub.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/plugins/ndf/scripts/tests/test_relay_hub.py b/plugins/ndf/scripts/tests/test_relay_hub.py index b7489015d..a6bb39e21 100644 --- a/plugins/ndf/scripts/tests/test_relay_hub.py +++ b/plugins/ndf/scripts/tests/test_relay_hub.py @@ -248,7 +248,19 @@ def test_ls_json_lists_state_from_files(dirs, capsys): assert rows["lflow"]["state"] == "awaiting_approval" and rows["lflow"]["awaiting_approval"] is True assert rows["lflow"]["sections"] == 3 for it in res["items"]: - assert set(it) == {"name", "state", "awaiting_approval", "sections", "cost_usd", "at", "account", "ndf_version", "attached", "hub", "role"} + assert set(it) == { + "name", + "state", + "awaiting_approval", + "sections", + "cost_usd", + "at", + "account", + "ndf_version", + "attached", + "hub", + "role", + } assert it["cost_usd"] is None and "最初" not in json.dumps(it, ensure_ascii=False) assert res["metrics"] == {"running": 2, "awaiting_approval": 1, "stopped": 0, "limit": 3, "hub": False, "tmux": False} assert hc.cmd_ls([]) == 0 From 7e3a3cbb5500239b040e399dcfdedd8283490c75 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 04:17:21 +0000 Subject: [PATCH 09/70] =?UTF-8?q?Fix:=20=E4=B8=A6=E5=88=97=E3=81=AE?= =?UTF-8?q?=E8=A8=88=E7=94=BB=E3=81=AE=E9=96=A2=E6=95=B0=E3=82=92=E6=97=A2?= =?UTF-8?q?=E5=AD=98=E3=81=A8=E9=87=8D=E3=81=AA=E3=82=89=E3=81=AA=E3=81=84?= =?UTF-8?q?=E5=90=8D=E5=89=8D=E3=81=B8=E6=94=B9=E3=82=81=E3=80=81=E9=85=8D?= =?UTF-8?q?=E5=B8=83=E3=81=AE=E4=BE=9D=E9=A0=BC=E3=81=AE=E5=BD=A2=E3=81=AE?= =?UTF-8?q?=E4=BE=8B=E3=82=92=E5=A5=91=E7=B4=84=E3=81=AE=E6=A4=9C=E6=9F=BB?= =?UTF-8?q?=E3=81=A7=E5=8F=97=E3=81=91=E3=82=8B=EF=BC=88#2436=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - script-structure の同名の違反: 新しく足した関数を改名した(durable の外からの読み・レーン・計画・実行・CLI) - parallel.py の deps.require に md・mdtable・versions を足した - contracts_lib のフォームの例の形に、メッセージと正否の組(parallel-request.json)を足した Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/durable.py | 16 +-- plugins/ndf/scripts/lib/parallel_lane.py | 26 ++-- plugins/ndf/scripts/lib/parallel_plan.py | 6 +- plugins/ndf/scripts/lib/parallel_release.py | 4 +- plugins/ndf/scripts/lib/parallel_run.py | 20 +-- plugins/ndf/scripts/parallel.py | 126 +++++++++--------- plugins/ndf/scripts/relay_lib/hub_client.py | 10 +- .../tests/test_parallel_request_form.py | 4 +- .../ndf/scripts/tests/test_parallel_run.py | 18 +-- scripts/contracts_lib/model.py | 12 +- scripts/tests/test_check_ndf_contracts.py | 4 + 11 files changed, 129 insertions(+), 117 deletions(-) diff --git a/plugins/ndf/scripts/lib/durable.py b/plugins/ndf/scripts/lib/durable.py index 569e42bf6..09f38a648 100644 --- a/plugins/ndf/scripts/lib/durable.py +++ b/plugins/ndf/scripts/lib/durable.py @@ -380,7 +380,7 @@ def set_event(key: str, value: Any) -> None: @contextlib.contextmanager -def client(kind: str, identity: str) -> Any: +def open_client(kind: str, identity: str) -> Any: """`record_path(kind, identity)` の SQLite を開いた `DBOSClient`。ファイルが無ければ `DurableError`。""" path = record_path(kind, identity) if not path.exists(): @@ -394,28 +394,28 @@ def client(kind: str, identity: str) -> Any: def send_to(kind: str, identity: str, wid: str, msg: Any, topic: str, key: str) -> None: """外から耐久ワークフロー `wid` へメッセージを送る。同じ `key` の 2 度目は数えない。""" - with client(kind, identity) as c: + with open_client(kind, identity) as c: c.send(wid, msg, topic, idempotency_key=key) -def event_of(kind: str, identity: str, wid: str, key: str, timeout: float = 0) -> Any: +def event_from(kind: str, identity: str, wid: str, key: str, timeout: float = 0) -> Any: """外から耐久ワークフロー `wid` のイベント `key` を読む。`timeout` 秒待ち、無ければ None。""" - with client(kind, identity) as c: + with open_client(kind, identity) as c: return c.get_event(wid, key, timeout_seconds=timeout) -def status_of(kind: str, identity: str, wid: str) -> Optional[str]: +def status_from(kind: str, identity: str, wid: str) -> Optional[str]: """外から耐久ワークフロー `wid` の状態を読む(記録が無い・耐久ワークフローが無いときは None)。""" if not record_path(kind, identity).exists(): return None - with client(kind, identity) as c: + with open_client(kind, identity) as c: rows = c.list_workflows(workflow_ids=[wid], load_input=False, load_output=False) return rows[0].status if rows else None def output_from(kind: str, identity: str, wid: str) -> Any: """外から、終わった耐久ワークフロー `wid` の出力を読む(無ければ None)。""" - with client(kind, identity) as c: + with open_client(kind, identity) as c: rows = c.list_workflows(workflow_ids=[wid], load_input=False, load_output=True) return rows[0].output if rows else None @@ -436,7 +436,7 @@ def wait_from( marks = {k: (after.get(k, 0) if isinstance(after, Mapping) else after) for k in names} deadline = None if timeout is None else time.monotonic() + timeout while True: - with client(kind, identity) as c: + with open_client(kind, identity) as c: rows = c.list_workflows(workflow_ids=[wid], load_input=False, load_output=True) if not rows: return Outcome("error", f"耐久ワークフロー {wid} が無い") diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py index b59eac2ee..43d8614de 100644 --- a/plugins/ndf/scripts/lib/parallel_lane.py +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -27,7 +27,7 @@ def workflow_id(name: str) -> str: return f"parallel-{name}" -def identity(main: Path, name: str) -> str: +def record_identity(main: Path, name: str) -> str: """耐久の記録の識別(計画の置き場の絶対パス)。""" return str(pp.plan_dir(main, name).resolve()) @@ -35,7 +35,7 @@ def identity(main: Path, name: str) -> str: def record_exists(main: Path, name: str) -> bool: from durable_keys import record_path - return record_path(KIND, identity(main, name)).exists() + return record_path(KIND, record_identity(main, name)).exists() def run_status(main: Path, name: str) -> Optional[str]: @@ -44,7 +44,7 @@ def run_status(main: Path, name: str) -> Optional[str]: return None import durable - return durable.status_of(KIND, identity(main, name), workflow_id(name)) + return durable.status_from(KIND, record_identity(main, name), workflow_id(name)) def plans_in(main: Path) -> list[str]: @@ -59,7 +59,7 @@ def lane_of(main: Path, lane: Optional[str] = None, status=run_status) -> Option return None for name in plans_in(main): try: - plan = pp.ParallelPlan.model_validate(pp.load(main, name), context={"max_plans": 10**6, "max_named": 10**6}) + plan = pp.ParallelPlan.model_validate(pp.load_plan(main, name), context={"max_plans": 10**6, "max_named": 10**6}) except (OSError, ValueError): continue hit = plan.lane(lane) @@ -93,11 +93,11 @@ def released_version(main: Path, name: str, prs: list[int]) -> Optional[str]: """`prs` を含む範囲のイベント `range-<連番>` の版(無ければ None)。""" import durable - ident, wid = identity(main, name), workflow_id(name) + ident, wid = record_identity(main, name), workflow_id(name) n = 1 found = None while True: - ev = durable.event_of(KIND, ident, wid, f"range-{n}", 0) + ev = durable.event_from(KIND, ident, wid, f"range-{n}", 0) if not isinstance(ev, dict): return found if set(prs) & set(ev.get("prs") or []): @@ -105,7 +105,7 @@ def released_version(main: Path, name: str, prs: list[int]) -> Optional[str]: n += 1 -def request_id(state: Path, lane: str) -> str: +def lane_request_id(state: Path, lane: str) -> str: """依頼の ID。最初の実行で `<レーン名>-r<乱数 12 桁>` を作って `work/request-id` へ書き、打ち直しでは読む。""" p = Path(state) / "work" / "request-id" if p.is_file() and p.read_text(encoding="utf-8").strip(): @@ -141,11 +141,11 @@ def request_errors(msg: object) -> list[str]: return errs -def send_request(main: Path, name: str, lane: str, prs: list[int], issues: list[int], state: Path) -> str: +def send_release_request(main: Path, name: str, lane: str, prs: list[int], issues: list[int], state: Path) -> str: """配布の依頼を話題 `inbox` へ送る。冪等の鍵は `:inbox:<依頼>`。依頼の ID を返す。""" import durable - rid = request_id(state, lane) + rid = lane_request_id(state, lane) wid = workflow_id(name) msg = { "kind": "request", @@ -156,7 +156,7 @@ def send_request(main: Path, name: str, lane: str, prs: list[int], issues: list[ "sprint_state": str(state), "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), } - durable.send_to(KIND, identity(main, name), wid, msg, "inbox", f"{wid}:inbox:{rid}") + durable.send_to(KIND, record_identity(main, name), wid, msg, "inbox", f"{wid}:inbox:{rid}") return rid @@ -164,14 +164,14 @@ def read_answer(main: Path, name: str, rid: str, timeout: float = 0) -> tuple[Op """(答え `answer-<依頼>`, 知らせ `defer-<依頼>`)。`timeout` 秒まで答えを待つ(0 は待たない)。知らせでは返らない(I10)。""" import durable - ident, wid = identity(main, name), workflow_id(name) + ident, wid = record_identity(main, name), workflow_id(name) deadline = time.monotonic() + max(0.0, timeout) while True: - ans = durable.event_of(KIND, ident, wid, f"answer-{rid}", 0) + ans = durable.event_from(KIND, ident, wid, f"answer-{rid}", 0) if isinstance(ans, dict): return ans, None if time.monotonic() >= deadline: - dfr = durable.event_of(KIND, ident, wid, f"defer-{rid}", 0) + dfr = durable.event_from(KIND, ident, wid, f"defer-{rid}", 0) return None, dfr if isinstance(dfr, dict) else None time.sleep(min(5.0, max(0.2, deadline - time.monotonic()))) diff --git a/plugins/ndf/scripts/lib/parallel_plan.py b/plugins/ndf/scripts/lib/parallel_plan.py index 2c906bee4..e14ffe283 100644 --- a/plugins/ndf/scripts/lib/parallel_plan.py +++ b/plugins/ndf/scripts/lib/parallel_plan.py @@ -46,7 +46,7 @@ def max_plans() -> tuple[int, Optional[str]]: return _positive_env("NDF_PARALLEL_MAX_PLANS", DEFAULT_MAX_PLANS) -def max_named() -> tuple[int, Optional[str]]: +def named_limit() -> tuple[int, Optional[str]]: return _positive_env("NDF_RELAY_MAX_NAMED", DEFAULT_MAX_NAMED) @@ -209,7 +209,7 @@ def _touch_hit(x: str, y: str) -> bool: def check_data(data: object) -> tuple[Optional[ParallelPlan], list[dict], dict]: """下書きか `plan.json` の中身を検査する。(計画か None, items, metrics) を返す。items の `level` は error / warning。""" plans_cap, plans_note = max_plans() - named_cap, named_note = max_named() + named_cap, named_note = named_limit() items: list[dict] = [] notes = [n for n in (plans_note, named_note) if n] try: @@ -248,7 +248,7 @@ def plan_dir(main: Path, name: str) -> Path: return Path(main) / PARALLEL_DIR / name -def load(main: Path, name: str) -> object: +def load_plan(main: Path, name: str) -> object: """`plan.json` の中身(JSON の値)。無ければ FileNotFoundError、読めなければ ValueError。""" import json diff --git a/plugins/ndf/scripts/lib/parallel_release.py b/plugins/ndf/scripts/lib/parallel_release.py index 731eab73d..0acd79e2c 100644 --- a/plugins/ndf/scripts/lib/parallel_release.py +++ b/plugins/ndf/scripts/lib/parallel_release.py @@ -73,7 +73,7 @@ def breakdown_md(mapped: dict, added: Optional[dict] = None) -> str: return "\n".join(lines) + "\n" -def closing_issues(root: Path, prs: list[int], run: Callable = subprocess.run) -> dict[int, list[int]]: +def closes_by_pr(root: Path, prs: list[int], run: Callable = subprocess.run) -> dict[int, list[int]]: """Pull Request が閉じる課題(`closingIssuesReferences`)。読めないものは空にする。""" out: dict[int, list[int]] = {} for n in prs: @@ -98,7 +98,7 @@ def present( out_prs: Path, presented_json: Path, first: Optional[dict] = None, - closes_of: Callable = closing_issues, + closes_of: Callable = closes_by_pr, ) -> dict: """承認資料から提示したコミットと含む Pull Request を読み、レーンへ対応づけて `presented.json` と `--out` を書く。 diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 16fb8d843..063d420fd 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -81,7 +81,7 @@ def dev_version(main: Path) -> str: @durable.step(name="parallel.init") def step_init(main: str, name: str) -> dict: """計画を読み、検査し、オーガナイザーの印を書く。""" - plan, items, _ = pp.check_data(pp.load(Path(main), name)) + plan, items, _ = pp.check_data(pp.load_plan(Path(main), name)) if plan is None: return {"ok": False, "items": items} relay(Path(main), "role", "organizer", "--plan", name) @@ -91,7 +91,7 @@ def step_init(main: str, name: str) -> dict: @durable.step(name="parallel.spawn") def step_spawn(main: str, name: str, lanes: list[str]) -> dict: """`lanes` を起動する。成功(同じ名前の 2 本目の断りも成功とする)は `started`、断りは `refused`(I7)。""" - plan = pp.ParallelPlan.model_validate(pp.load(Path(main), name), context={"max_plans": 10**6, "max_named": 10**6}) + plan = pp.ParallelPlan.model_validate(pp.load_plan(Path(main), name), context={"max_plans": 10**6, "max_named": 10**6}) out = {} for n in lanes: ln = plan.lane(n) @@ -142,17 +142,17 @@ def step_stop_lanes(main: str, names: list[str]) -> list[str]: def step_finish(main: str, name: str, progress: dict) -> None: """役割の印を消し、`plan.md` を描画し直す。""" relay(Path(main), "role", "--clear") - render(main, name, progress) + render_progress(main, name, progress) @durable.step(name="parallel.render") def step_render(main: str, name: str, progress: dict) -> None: - render(main, name, progress) + render_progress(main, name, progress) -def render(main: str, name: str, progress: dict) -> None: +def render_progress(main: str, name: str, progress: dict) -> None: try: - plan = pp.ParallelPlan.model_validate(pp.load(Path(main), name), context={"max_plans": 10**6, "max_named": 10**6}) + plan = pp.ParallelPlan.model_validate(pp.load_plan(Path(main), name), context={"max_plans": 10**6, "max_named": 10**6}) except (OSError, ValueError): return (pp.plan_dir(Path(main), name) / "plan.md").write_text(pp.render_md(plan, progress), encoding="utf-8") @@ -376,14 +376,14 @@ def _plan_view(run: _Run) -> pp.ParallelPlan: # ---- 外の入口 ---- -def open_record(main: Path, name: str, lock_timeout: Optional[float] = 0) -> None: - durable.launch(pl.KIND, pl.identity(main, name), keep=pl.workflow_id(name), lock_timeout=lock_timeout) +def open_parallel_record(main: Path, name: str, lock_timeout: Optional[float] = 0) -> None: + durable.launch(pl.KIND, pl.record_identity(main, name), keep=pl.workflow_id(name), lock_timeout=lock_timeout) -def run(main: Path, name: str) -> tuple[dict, bool]: +def run_plan(main: Path, name: str) -> tuple[dict, bool]: """耐久ワークフローを始めるか続け、終わるまで待つ。(出力, 再開か) を返す。""" wid = pl.workflow_id(name) - open_record(main, name) + open_parallel_record(main, name) st = durable.status(wid) resumed = st is not None if st is None: diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index 5da53a551..7d8c3d403 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -33,7 +33,7 @@ import deps # noqa: E402 -deps.require("durable", "schema") +deps.require("durable", "schema", "md", "mdtable", "versions") import decl # noqa: E402 import parallel_lane as pl # noqa: E402 @@ -49,17 +49,17 @@ def main_of() -> Path: return decl.main_dir(cwd) or Path(cwd) -def out(status: str, summary: str, items=None, code: int | None = None, **metrics): +def reply(status: str, summary: str, items=None, code: int | None = None, **metrics): emit(result(TOOL, status, summary, items, metrics), code) -def load_plan(main: Path, name: str) -> tuple[pp.ParallelPlan | None, list[dict], dict]: +def checked_plan(main: Path, name: str) -> tuple[pp.ParallelPlan | None, list[dict], dict]: try: - data = pp.load(main, name) + data = pp.load_plan(main, name) except FileNotFoundError: - out("stopped", f"並列の計画 {name} が無い(置き場: {pp.plan_dir(main, name)}/plan.json)", code=1) + reply("stopped", f"並列の計画 {name} が無い(置き場: {pp.plan_dir(main, name)}/plan.json)", code=1) except (OSError, ValueError) as exc: - out("stopped", f"並列の計画 {name} を読めない: {exc}", code=2) + reply("stopped", f"並列の計画 {name} を読めない: {exc}", code=2) return pp.check_data(data) @@ -71,27 +71,27 @@ def cmd_init(a) -> None: try: raw = json.loads(sys.stdin.read()) except ValueError as exc: - out("stopped", f"下書きを読めない: {exc}", code=2) + reply("stopped", f"下書きを読めない: {exc}", code=2) if not isinstance(raw, dict): - out("stopped", "下書きが JSON のオブジェクトでない", code=2) + reply("stopped", "下書きが JSON のオブジェクトでない", code=2) raw = {**raw, "name": a.name} plan, items, metrics = pp.check_data(raw) if plan is None: - out("stopped", f"並列の計画 {a.name} の検査に誤りがある({metrics['errors']} 件)。何も書いていない", items, 1, **metrics) + reply("stopped", f"並列の計画 {a.name} の検査に誤りがある({metrics['errors']} 件)。何も書いていない", items, 1, **metrics) try: written = pp.write_all(main, plan, raw) except FileExistsError as exc: - out("stopped", f"在る文書を上書きしないため何も書いていない: {exc}", items, 1, existing=str(exc).split(", ")) + reply("stopped", f"在る文書を上書きしないため何も書いていない: {exc}", items, 1, existing=str(exc).split(", ")) except OSError as exc: - out("stopped", f"書けない: {exc}", items, 2) - out("ok", f"並列の計画 {a.name} を書いた(レーン {len(plan.lanes)} 本)", [{"path": str(p)} for p in written] + items, **metrics) + reply("stopped", f"書けない: {exc}", items, 2) + reply("ok", f"並列の計画 {a.name} を書いた(レーン {len(plan.lanes)} 本)", [{"path": str(p)} for p in written] + items, **metrics) def cmd_check(a) -> None: - plan, items, metrics = load_plan(main_of(), a.name) + plan, items, metrics = checked_plan(main_of(), a.name) if plan is None: - out("stopped", f"並列の計画 {a.name} の検査に誤りがある({metrics['errors']} 件)", items, 1, **metrics) - out("ok", f"並列の計画 {a.name} の検査を通った(警告 {metrics['warnings']} 件)", items, **metrics) + reply("stopped", f"並列の計画 {a.name} の検査に誤りがある({metrics['errors']} 件)", items, 1, **metrics) + reply("ok", f"並列の計画 {a.name} の検査を通った(警告 {metrics['warnings']} 件)", items, **metrics) # ---- 進行 ---- @@ -113,7 +113,7 @@ def _manual_or_none(main: Path, plan: pp.ParallelPlan) -> None: why = "統括役が居ない" if why: lines = pp.manual_commands(plan) - out( + reply( "stopped", f"{why}ため、レーンを起動しない。手で起動する 1 行を items に並べた", [{"lane": ln.name, "command": c} for ln, c in zip(plan.lanes, lines)], @@ -128,37 +128,37 @@ def _wait_line(name: str) -> str: def cmd_run(a) -> None: main = main_of() - plan, items, metrics = load_plan(main, a.name) + plan, items, metrics = checked_plan(main, a.name) if plan is None: - out("stopped", f"並列の計画 {a.name} の検査に誤りがあるため、レーンを 1 本も起動しない", items, 1, **metrics) + reply("stopped", f"並列の計画 {a.name} の検査に誤りがあるため、レーンを 1 本も起動しない", items, 1, **metrics) if pl.run_status(main, a.name) is None: _manual_or_none(main, plan) import durable import parallel_run as pr_run try: - got, resumed = pr_run.run(main, a.name) + got, resumed = pr_run.run_plan(main, a.name) except durable.DurableError as exc: if "動いている" in str(exc): # 設計の終了コード 5 は統一 JSON レポートの終了コードの決まり(step_result.code_matches)に無いため、 # 動いている run を続ける答え(ok・running)として返す - out("ok", f"同じ計画の run が動いている。wait で待つ: {exc}", running=True, next=_wait_line(a.name)) - out("stopped", f"耐久の記録を開けない: {exc}", code=2) + reply("ok", f"同じ計画の run が動いている。wait で待つ: {exc}", running=True, next=_wait_line(a.name)) + reply("stopped", f"耐久の記録を開けない: {exc}", code=2) status = got.get("status") if isinstance(got, dict) else None lanes = [{"lane": n, **st} for n, st in sorted((got or {}).get("lanes", {}).items())] - out("ok", f"並列の計画 {a.name} は {status} で終わった", lanes, resumed=resumed, action=status, releases=(got or {}).get("releases")) + reply("ok", f"並列の計画 {a.name} は {status} で終わった", lanes, resumed=resumed, action=status, releases=(got or {}).get("releases")) def cmd_start(a) -> None: main = main_of() - plan, items, metrics = load_plan(main, a.name) + plan, items, metrics = checked_plan(main, a.name) if plan is None: - out("stopped", f"並列の計画 {a.name} の検査に誤りがあるため、レーンを 1 本も起動しない", items, 1, **metrics) + reply("stopped", f"並列の計画 {a.name} の検査に誤りがあるため、レーンを 1 本も起動しない", items, 1, **metrics) pidfile = pp.plan_dir(main, a.name) / "run.pid" try: pid = int(pidfile.read_text().strip()) os.kill(pid, 0) - out("ok", f"run は動いている(pid {pid})", pid=pid, next=_wait_line(a.name)) + reply("ok", f"run は動いている(pid {pid})", pid=pid, next=_wait_line(a.name)) except (OSError, ValueError): pass if pl.run_status(main, a.name) is None: @@ -167,7 +167,7 @@ def cmd_start(a) -> None: with open(log, "ab") as f: p = subprocess.Popen([sys.executable, str(SELF), "run", a.name], cwd=main, stdout=f, stderr=f, start_new_session=True) pidfile.write_text(f"{p.pid}\n") - out("ok", f"run を切り離して起動した(pid {p.pid})", pid=p.pid, log=str(log), next=_wait_line(a.name)) + reply("ok", f"run を切り離して起動した(pid {p.pid})", pid=p.pid, log=str(log), next=_wait_line(a.name)) def cmd_wait(a) -> None: @@ -175,10 +175,10 @@ def cmd_wait(a) -> None: main = main_of() if not pl.record_exists(main, a.name): - out("stopped", f"並列の計画 {a.name} の耐久の記録が無い", code=2) - got = durable.wait_from(pl.KIND, pl.identity(main, a.name), pl.workflow_id(a.name), ["pause", "questions"], a.after, a.timeout) + reply("stopped", f"並列の計画 {a.name} の耐久の記録が無い", code=2) + got = durable.wait_from(pl.KIND, pl.record_identity(main, a.name), pl.workflow_id(a.name), ["pause", "questions"], a.after, a.timeout) if got.kind == "timeout": - out( + reply( "stopped", f"{a.timeout:.0f} 秒の上限に達した。同じ wait を打ち直す", code=3, @@ -190,34 +190,34 @@ def cmd_wait(a) -> None: key = ev.pop("key") action = ev.get("action") if key == "pause" else "questions" if action == "running": - out("stopped", "まだ変化が無い", code=3, seq=ev.get("seq")) + reply("stopped", "まだ変化が無い", code=3, seq=ev.get("seq")) items = ev.pop("items", None) or [] - out("ok", f"並列の計画 {a.name}: {action}", items, action=action, **ev) + reply("ok", f"並列の計画 {a.name}: {action}", items, action=action, **ev) if got.kind == "done": o = got.value or {} lanes = [{"lane": n, **st} for n, st in sorted((o.get("lanes") or {}).items())] - out("ok", f"並列の計画 {a.name} は {o.get('status')} で終わった", lanes, action=o.get("status"), releases=o.get("releases")) - out("stopped", f"耐久ワークフローが {got.kind} で終わった: {got.value}", code=1, action=got.kind) + reply("ok", f"並列の計画 {a.name} は {o.get('status')} で終わった", lanes, action=o.get("status"), releases=o.get("releases")) + reply("stopped", f"耐久ワークフローが {got.kind} で終わった: {got.value}", code=1, action=got.kind) def cmd_present(a) -> None: import parallel_release as pr main = main_of() - plan, items, metrics = load_plan(main, a.name) + plan, items, metrics = checked_plan(main, a.name) if plan is None: - out("stopped", f"並列の計画 {a.name} の検査に誤りがある", items, 1) + reply("stopped", f"並列の計画 {a.name} の検査に誤りがある", items, 1) d = pr.release_dir(main, a.name, a.n, a.k) first = json.loads(Path(a.first).read_text(encoding="utf-8")) if a.first and Path(a.first).is_file() else None reqs = _requests_for(main, a.name) try: got = pr.present(main, plan, Path(a.approval), reqs, Path(a.out), d / "presented.json", first) except ValueError as exc: - out("stopped", f"承認資料に承認したコミットが無い: {exc}", code=1) + reply("stopped", f"承認資料に承認したコミットが無い: {exc}", code=1) except OSError as exc: - out("stopped", f"読めない: {exc}", code=2) + reply("stopped", f"読めない: {exc}", code=2) Path(a.out).with_name("approved.txt").write_text(got["sha"] + "\n", encoding="utf-8") - out( + reply( "ok", f"提示 {a.n}-{a.k}: {got['sha'][:12]} まで(Pull Request {len(got['prs'])} 本)", [{"lane": n, **r} for n, r in got["lanes"].items()], @@ -235,42 +235,42 @@ def _requests_for(main: Path, name: str) -> list[dict]: return [] -def _resume(main: Path, name: str, msg: dict, key: str) -> None: +def _resume_plan(main: Path, name: str, msg: dict, key: str) -> None: import durable - durable.send_to(pl.KIND, pl.identity(main, name), pl.workflow_id(name), msg, "resume", key) + durable.send_to(pl.KIND, pl.record_identity(main, name), pl.workflow_id(name), msg, "resume", key) def cmd_confirm(a) -> None: main = main_of() if pl.run_status(main, a.name) not in pl.RUNNING: - out("stopped", f"並列の計画 {a.name} が動いていない", code=1) + reply("stopped", f"並列の計画 {a.name} が動いていない", code=1) wid = pl.workflow_id(a.name) - _resume(main, a.name, {"n": a.n, "phase": "confirm", "take": a.take}, f"{wid}:resume:confirm:{a.n}:{a.take or '-'}") - out("ok", f"配布 {a.n} の承認を送った", n=a.n, take=a.take) + _resume_plan(main, a.name, {"n": a.n, "phase": "confirm", "take": a.take}, f"{wid}:resume:confirm:{a.n}:{a.take or '-'}") + reply("ok", f"配布 {a.n} の承認を送った", n=a.n, take=a.take) def cmd_settle(a) -> None: main = main_of() if pl.run_status(main, a.name) not in pl.RUNNING: - out("stopped", f"並列の計画 {a.name} が動いていない", code=1) + reply("stopped", f"並列の計画 {a.name} が動いていない", code=1) if a.rejected: res, version = "rejected", None else: try: done = json.loads(Path(a.done).read_text(encoding="utf-8")) except (OSError, ValueError) as exc: - out("stopped", f"done を読めない: {exc}", code=2) + reply("stopped", f"done を読めない: {exc}", code=2) results = [str(i.get("result")) for i in done.get("items") or [] if isinstance(i, dict)] if "関門" in results: - out("stopped", "本番のプランが承認ゲートで止まっている(送らない)", code=4) + reply("stopped", "本番のプランが承認ゲートで止まっている(送らない)", code=4) res = "released" if results and all(r == "完了" for r in results) else "failed" version = done.get("version") or (done.get("metrics") or {}).get("version") wid = pl.workflow_id(a.name) - _resume( + _resume_plan( main, a.name, {"n": a.n, "phase": "settle", "result": res, "version": version, "reason": a.reason}, f"{wid}:resume:settle:{a.n}" ) - out("ok", f"配布 {a.n} の結果 {res} を送った", result=res, version=version) + reply("ok", f"配布 {a.n} の結果 {res} を送った", result=res, version=version) def cmd_stop(a) -> None: @@ -278,16 +278,16 @@ def cmd_stop(a) -> None: import parallel_run as pr_run main = main_of() - plan, items, _ = load_plan(main, a.name) + plan, items, _ = checked_plan(main, a.name) if plan is None: - out("stopped", f"並列の計画 {a.name} の検査に誤りがある", items, 1) + reply("stopped", f"並列の計画 {a.name} の検査に誤りがある", items, 1) if pl.run_status(main, a.name) in pl.RUNNING: wid = pl.workflow_id(a.name) - durable.send_to(pl.KIND, pl.identity(main, a.name), wid, {"kind": "stop"}, "inbox", f"{wid}:inbox:stop") + durable.send_to(pl.KIND, pl.record_identity(main, a.name), wid, {"kind": "stop"}, "inbox", f"{wid}:inbox:stop") left = [ln.name for ln in plan.lanes if pr_run.relay(main, "stop", ln.name, "--json")[0] not in (0, 3)] if left: - out("stopped", f"止まらないレーンがある: {', '.join(left)}", [{"lane": n} for n in left], 1, not_stopped=left) - out("ok", f"並列の計画 {a.name} のレーン {len(plan.lanes)} 本へ停止を打った", [{"lane": ln.name} for ln in plan.lanes]) + reply("stopped", f"止まらないレーンがある: {', '.join(left)}", [{"lane": n} for n in left], 1, not_stopped=left) + reply("ok", f"並列の計画 {a.name} のレーン {len(plan.lanes)} 本へ停止を打った", [{"lane": ln.name} for ln in plan.lanes]) # ---- レーンの側 ---- @@ -296,14 +296,14 @@ def cmd_stop(a) -> None: def cmd_request(a) -> None: main = main_of() if pl.run_status(main, a.name) not in pl.RUNNING: - out("stopped", f"並列の計画 {a.name} が動いていない", code=1) + reply("stopped", f"並列の計画 {a.name} が動いていない", code=1) if a.prs and pl.merged_on_main(main, a.prs): version = pl.released_version(main, a.name, a.prs) pl.write_out(Path(a.out), {"result": "released", "version": version, "prs": a.prs}) - out("ok", "自分の Pull Request はすでに main にある。依頼を送らずに続きへ進む", result="released", version=version) - rid = pl.send_request(main, a.name, a.lane, a.prs, a.issues, Path(a.state)) + reply("ok", "自分の Pull Request はすでに main にある。依頼を送らずに続きへ進む", result="released", version=version) + rid = pl.send_release_request(main, a.name, a.lane, a.prs, a.issues, Path(a.state)) pl.write_out(Path(a.out), {"id": rid, "plan": a.name, "lane": a.lane, "prs": a.prs}) - out("gate", f"配布の依頼 {rid} を送った(配布待ち)。答えは parallel.py await で読む", code=10, id=rid, result="requested") + reply("gate", f"配布の依頼 {rid} を送った(配布待ち)。答えは parallel.py await で読む", code=10, id=rid, result="requested") def cmd_await(a) -> None: @@ -311,16 +311,16 @@ def cmd_await(a) -> None: try: sent = json.loads(Path(getattr(a, "from")).read_text(encoding="utf-8")) except (OSError, ValueError) as exc: - out("stopped", f"依頼の記録を読めない: {exc}", code=2) + reply("stopped", f"依頼の記録を読めない: {exc}", code=2) if sent.get("result") == "released" and not sent.get("id"): - out("ok", "配布済み(依頼を送っていない)", result="released", version=sent.get("version"), deferred=None) + reply("ok", "配布済み(依頼を送っていない)", result="released", version=sent.get("version"), deferred=None) ans, dfr = pl.read_answer(main, a.name, sent["id"], a.timeout) if ans is None: - out("stopped", "答えがまだ無い", code=3, deferred=dfr) + reply("stopped", "答えがまだ無い", code=3, deferred=dfr) res = ans.get("result") code = {"released": 0, "rejected": 10}.get(res, 1) status = {0: "ok", 10: "gate"}.get(code, "stopped") - out( + reply( status, f"配布の依頼 {sent['id']} の答え: {res}", code=code, @@ -332,7 +332,7 @@ def cmd_await(a) -> None: ) -def parser() -> argparse.ArgumentParser: +def build_parser() -> argparse.ArgumentParser: ap = argparse.ArgumentParser(prog="parallel.py", description=__doc__.splitlines()[0]) sub = ap.add_subparsers(dest="cmd", required=True) for n in ("init", "check", "start", "run", "stop"): @@ -374,7 +374,7 @@ def parser() -> argparse.ArgumentParser: def main(argv=None) -> None: - a = parser().parse_args(argv) + a = build_parser().parse_args(argv) { "init": cmd_init, "check": cmd_check, diff --git a/plugins/ndf/scripts/relay_lib/hub_client.py b/plugins/ndf/scripts/relay_lib/hub_client.py index a8d566912..bdead5e25 100644 --- a/plugins/ndf/scripts/relay_lib/hub_client.py +++ b/plugins/ndf/scripts/relay_lib/hub_client.py @@ -188,7 +188,7 @@ def _split_dashdash(argv: list[str]) -> tuple[list[str], list[str]]: return argv, [] -def _opt(opts: list[str], flag: str) -> str | None: +def _flag_value(opts: list[str], flag: str) -> str | None: if flag not in opts: return None i = opts.index(flag) @@ -198,8 +198,8 @@ def _opt(opts: list[str], flag: str) -> str | None: def cmd_spawn(argv: list[str]) -> int: opts, rest = _split_dashdash(argv) as_json = "--json" in opts - name = _opt(opts, "--name") - role, plan = _opt(opts, "--role"), _opt(opts, "--plan") + name = _flag_value(opts, "--name") + role, plan = _flag_value(opts, "--role"), _flag_value(opts, "--plan") if not name or (role is not None and (role != "helper" or not plan)): print("usage: relay.py spawn --name <名前> [--role helper --plan <計画名>] [--json] -- <最初の入力>", file=sys.stderr) return 2 @@ -218,8 +218,8 @@ def cmd_role(argv: list[str]) -> int: """`relay.py role organizer --plan <計画名>` / `relay.py role --clear`。作業ディレクトリの役割のファイルを書く・消す(#2436)。""" relay_dir = os.environ.get("NDF_RELAY_DIR") clear = "--clear" in argv - role = next((a for a in argv if not a.startswith("-") and a != _opt(argv, "--plan")), None) - plan = _opt(argv, "--plan") + role = next((a for a in argv if not a.startswith("-") and a != _flag_value(argv, "--plan")), None) + plan = _flag_value(argv, "--plan") if not clear and (role not in hb.ROLES or not plan): print("usage: relay.py role organizer|helper --plan <計画名> | relay.py role --clear", file=sys.stderr) return 2 diff --git a/plugins/ndf/scripts/tests/test_parallel_request_form.py b/plugins/ndf/scripts/tests/test_parallel_request_form.py index 9d2ae7057..dea08e4dd 100644 --- a/plugins/ndf/scripts/tests/test_parallel_request_form.py +++ b/plugins/ndf/scripts/tests/test_parallel_request_form.py @@ -21,5 +21,5 @@ def test_cases(case): def test_request_id_is_stable_and_well_formed(tmp_path): - rid = pl.request_id(tmp_path, "lroot") - assert pl.request_id(tmp_path, "lroot") == rid and pl.REQUEST_ID_RE.match(rid) + rid = pl.lane_request_id(tmp_path, "lroot") + assert pl.lane_request_id(tmp_path, "lroot") == rid and pl.REQUEST_ID_RE.match(rid) diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index cbcad2bcd..1d05d482a 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -102,7 +102,7 @@ def until(pred, timeout=30): def start(main): - prun.open_record(main, "p1") + prun.open_parallel_record(main, "p1") durable.start(durable.WorkflowRef(pl.workflow_id("p1"), 1, "start"), prun.parallel_workflow, str(main), "p1") @@ -116,17 +116,17 @@ def test_flow_spawns_in_order_releases_without_waiting_and_finishes(env, tmp_pat until(lambda: spawned(log) == ["la"]) # lb は la が止まるまで起動しない(I8) states.write_text(json.dumps({"la": "running"})) # la の依頼 1 件で、他のレーンを待たずに提示 1 を組む(I9) - rid = pl.send_request(main, "p1", "la", [10], [1], tmp_path / "st") + rid = pl.send_release_request(main, "p1", "la", [10], [1], tmp_path / "st") ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release" and e) assert ev["n"] == 1 and ev["k"] == 1 and "--role helper" in ev["spawn"] d = prl.release_dir(main, "p1", 1, 1) (d / "presented.json").write_text(json.dumps({"sha": "sha1", "prs": [10, 11]})) # 先頭が同じなら確定(I14) - durable.send_to(pl.KIND, pl.identity(main, "p1"), pl.workflow_id("p1"), {"n": 1, "phase": "confirm"}, "resume", "c1") + durable.send_to(pl.KIND, pl.record_identity(main, "p1"), pl.workflow_id("p1"), {"n": 1, "phase": "confirm"}, "resume", "c1") until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "confirmed") durable.send_to( pl.KIND, - pl.identity(main, "p1"), + pl.record_identity(main, "p1"), pl.workflow_id("p1"), {"n": 1, "phase": "settle", "result": "released", "version": "1.2.3"}, "resume", @@ -147,11 +147,11 @@ def test_flow_spawns_in_order_releases_without_waiting_and_finishes(env, tmp_pat def test_moved_head_represents_once(env, tmp_path): main, heads = env["main"], env["heads"] start(main) - pl.send_request(main, "p1", "la", [10], [1], tmp_path / "st") + pl.send_release_request(main, "p1", "la", [10], [1], tmp_path / "st") until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release") (prl.release_dir(main, "p1", 1, 1) / "presented.json").write_text(json.dumps({"sha": "sha1", "prs": [10]})) heads["v"] = "sha2" - send = lambda msg, key: durable.send_to(pl.KIND, pl.identity(main, "p1"), pl.workflow_id("p1"), msg, "resume", key) # noqa: E731 + send = lambda msg, key: durable.send_to(pl.KIND, pl.record_identity(main, "p1"), pl.workflow_id("p1"), msg, "resume", key) # noqa: E731 send({"n": 1, "phase": "confirm"}, "c1") ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("represent") and e) assert ev["k"] == 2 @@ -167,7 +167,7 @@ def test_stop_ends_workflow_and_stops_lanes(env): start(main) until(lambda: spawned(log) == ["la"]) wid = pl.workflow_id("p1") - durable.send_to(pl.KIND, pl.identity(main, "p1"), wid, {"kind": "stop"}, "inbox", f"{wid}:inbox:stop") + durable.send_to(pl.KIND, pl.record_identity(main, "p1"), wid, {"kind": "stop"}, "inbox", f"{wid}:inbox:stop") out = durable.output_of(wid) assert out["status"] == "stopped" and ["stop", "la", "--json"] in calls(log, "stop") @@ -176,8 +176,8 @@ def test_send_request_is_idempotent(env, tmp_path): main = env["main"] start(main) st = tmp_path / "st" - a = pl.send_request(main, "p1", "la", [10], [1], st) - b = pl.send_request(main, "p1", "la", [10], [1], st) + a = pl.send_release_request(main, "p1", "la", [10], [1], st) + b = pl.send_release_request(main, "p1", "la", [10], [1], st) assert a == b and (st / "work" / "request-id").read_text().strip() == a until(lambda: (event(main, "p1", "lane-la") or {}).get("received") == 1) time.sleep(0.6) diff --git a/scripts/contracts_lib/model.py b/scripts/contracts_lib/model.py index a8328d9f3..0882ead28 100644 --- a/scripts/contracts_lib/model.py +++ b/scripts/contracts_lib/model.py @@ -185,6 +185,13 @@ def _load_forms(base: Path, c: Contracts) -> None: c.forms[d["name"]] = d +# 例の組の形。ndf-next のブロック(文 → 拾うブロックと起動の有無)と、メッセージの形(メッセージ → 正しいか。#2436 の配布の依頼) +CASE_SHAPES: tuple[dict[str, type], ...] = ( + {"text": str, "blocks": list, "launch": bool}, + {"message": dict, "valid": bool}, +) + + def _check_form(f: Path, d: dict) -> None: """受け渡しの形(`forms/`): 規則の文と、読み手が同じ答えを返すべき例の組(#2347 の決定 8)。""" _need(f, d, {"name": str, "summary": str, "rule": str, "writers": list, "readers": list, "cases": list}) @@ -197,8 +204,9 @@ def _check_form(f: Path, d: dict) -> None: for i, case in enumerate(d["cases"]): if not isinstance(case, dict): raise ContractError(f"{f.name}: cases[{i}] はオブジェクト") - _need(f, case, {"text": str, "blocks": list, "launch": bool}) - if not all(isinstance(x, str) for x in case["blocks"]): + shape = next((sh for sh in CASE_SHAPES if set(case) == set(sh)), CASE_SHAPES[0]) + _need(f, case, shape) + if "blocks" in shape and not all(isinstance(x, str) for x in case["blocks"]): raise ContractError(f"{f.name}: cases[{i}].blocks は文字列の並び") diff --git a/scripts/tests/test_check_ndf_contracts.py b/scripts/tests/test_check_ndf_contracts.py index c579d6c59..d216da5ca 100644 --- a/scripts/tests/test_check_ndf_contracts.py +++ b/scripts/tests/test_check_ndf_contracts.py @@ -787,6 +787,10 @@ def test_form_items_are_checked_and_unknown_keys_stop_with_2(tree: Path): assert run(tree)[0] == 2 wj(f, dict(FORM, cases=[{"text": "t", "blocks": [1], "launch": False}])) assert run(tree)[0] == 2 + wj(f, dict(FORM, cases=[{"message": {"kind": "request"}, "valid": True}])) + assert run(tree)[0] == 0 + wj(f, dict(FORM, cases=[{"message": "m", "valid": True}])) + assert run(tree)[0] == 2 # --- model.load: 区分ごとの読み込みと形の検査(現状固定) --- From e9bf88deebd09921bc838e00ef7daa049635f303 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 04:43:20 +0000 Subject: [PATCH 10/70] =?UTF-8?q?Fix:=20=E4=B8=A6=E5=88=97=E3=81=AE?= =?UTF-8?q?=E8=A8=88=E7=94=BB=E3=81=AE=E6=9B=B8=E3=81=8D=E5=85=88=E3=82=92?= =?UTF-8?q?=E7=8A=B6=E6=85=8B=E3=83=87=E3=82=A3=E3=83=AC=E3=82=AF=E3=83=88?= =?UTF-8?q?=E3=83=AA=E3=81=AE=E6=B1=BA=E3=81=BE=E3=81=A3=E3=81=9F=E5=90=8D?= =?UTF-8?q?=E5=89=8D=E3=81=AB=E3=81=97=E3=80=81=E5=BC=95=E7=B6=99=E3=81=8E?= =?UTF-8?q?=E6=96=87=E6=9B=B8=E3=82=92=20handoff.py=20=E3=81=B8=E5=AF=84?= =?UTF-8?q?=E3=81=9B=E3=80=81=E3=83=99=E3=83=BC=E3=82=B9=E3=83=96=E3=83=A9?= =?UTF-8?q?=E3=83=B3=E3=83=81=E3=81=A8=E9=85=8D=E3=82=8B=E3=83=97=E3=83=A9?= =?UTF-8?q?=E3=82=B0=E3=82=A4=E3=83=B3=E3=82=92=E5=AE=A3=E8=A8=80=E3=81=8B?= =?UTF-8?q?=E3=82=89=E8=AA=AD=E3=82=80=EF=BC=88#2436=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 契約の検査の file-dynamic・env-dynamic を例外リストへ足さずに直し、parallel.py と新しい受け渡しのファイル・環境変数の契約を足した。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KYHP7xWdWHdeDeY7afGnGT --- .../plugins__ndf__scripts__parallel.py.json | 177 ++++++++++++++++++ .../plugins__ndf__scripts__supervise.py.json | 13 ++ .../contracts/env/NDF_PARALLEL_MAX_PLANS.json | 5 + .../ndf/contracts/env/NDF_PARALLEL_POLL.json | 5 + .../ndf/contracts/env/NDF_PARALLEL_RELAY.json | 5 + .../contracts/env/NDF_RELAY_MAX_NAMED.json | 5 + plugins/ndf/contracts/files/answer-json.json | 7 + plugins/ndf/contracts/files/approved-txt.json | 7 + plugins/ndf/contracts/files/breakdown-md.json | 7 + plugins/ndf/contracts/files/plan-json.json | 7 + plugins/ndf/contracts/files/plan-md.json | 7 + plugins/ndf/contracts/files/present-json.json | 7 + .../ndf/contracts/files/presented-json.json | 7 + plugins/ndf/contracts/files/prs-txt.json | 7 + .../ndf/contracts/files/request-id-txt.json | 7 + plugins/ndf/contracts/files/request-txt.json | 7 + plugins/ndf/contracts/files/run-log.json | 7 + plugins/ndf/contracts/files/run-pid.json | 7 + plugins/ndf/contracts/index.md | 46 +++-- plugins/ndf/scripts/lib/parallel_lane.py | 14 +- plugins/ndf/scripts/lib/parallel_plan.py | 40 ++-- plugins/ndf/scripts/lib/parallel_release.py | 34 ++-- plugins/ndf/scripts/lib/parallel_run.py | 44 ++++- plugins/ndf/scripts/parallel.py | 37 ++-- .../ndf/scripts/tests/test_parallel_plan.py | 13 +- .../ndf/scripts/tests/test_parallel_run.py | 8 +- .../references/parallel.md | 2 +- .../references/parallel-plan.md | 3 +- 28 files changed, 453 insertions(+), 82 deletions(-) create mode 100644 plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json create mode 100644 plugins/ndf/contracts/env/NDF_PARALLEL_MAX_PLANS.json create mode 100644 plugins/ndf/contracts/env/NDF_PARALLEL_POLL.json create mode 100644 plugins/ndf/contracts/env/NDF_PARALLEL_RELAY.json create mode 100644 plugins/ndf/contracts/env/NDF_RELAY_MAX_NAMED.json create mode 100644 plugins/ndf/contracts/files/answer-json.json create mode 100644 plugins/ndf/contracts/files/approved-txt.json create mode 100644 plugins/ndf/contracts/files/breakdown-md.json create mode 100644 plugins/ndf/contracts/files/plan-json.json create mode 100644 plugins/ndf/contracts/files/plan-md.json create mode 100644 plugins/ndf/contracts/files/present-json.json create mode 100644 plugins/ndf/contracts/files/presented-json.json create mode 100644 plugins/ndf/contracts/files/prs-txt.json create mode 100644 plugins/ndf/contracts/files/request-id-txt.json create mode 100644 plugins/ndf/contracts/files/request-txt.json create mode 100644 plugins/ndf/contracts/files/run-log.json create mode 100644 plugins/ndf/contracts/files/run-pid.json diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json new file mode 100644 index 000000000..911a87204 --- /dev/null +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json @@ -0,0 +1,177 @@ +{ + "version": 1, + "path": "plugins/ndf/scripts/parallel.py", + "summary": "並列の計画(`.ndf/parallel/<計画名>/`)の下書き・検査・起動・進行の待ち・まとめた配布の提示と確定・レーンの配布の依頼と答え(#2436)。結果は統一 JSON レポートを 1 行で標準出力へ出す。", + "args": { + "options": [], + "positionals": [], + "subcommands": { + "await": { + "options": [ + "--from", + "--timeout" + ], + "positionals": [ + "name" + ] + }, + "check": { + "options": [], + "positionals": [ + "name" + ] + }, + "confirm": { + "options": [ + "--take" + ], + "positionals": [ + "name", + "n" + ] + }, + "init": { + "options": [], + "positionals": [ + "name" + ] + }, + "present": { + "options": [ + "--approval" + ], + "positionals": [ + "name", + "n", + "k" + ] + }, + "request": { + "options": [ + "--issues", + "--lane", + "--prs", + "--state" + ], + "positionals": [ + "name" + ] + }, + "run": { + "options": [], + "positionals": [ + "name" + ] + }, + "settle": { + "options": [ + "--done", + "--reason", + "--rejected" + ], + "positionals": [ + "name", + "n" + ] + }, + "start": { + "options": [], + "positionals": [ + "name" + ] + }, + "stop": { + "options": [], + "positionals": [ + "name" + ] + }, + "wait": { + "options": [ + "--after", + "--timeout" + ], + "positionals": [ + "name" + ] + } + } + }, + "env": [ + "GH_TOKEN", + "GITHUB_TOKEN", + "NDF_DEPS_REEXEC", + "NDF_DEPS_VENV", + "NDF_ESTIMATE_DIR", + "NDF_PARALLEL_MAX_PLANS", + "NDF_PARALLEL_POLL", + "NDF_PARALLEL_RELAY", + "NDF_PRESENTATION_DIR", + "NDF_RELAY_DIR", + "NDF_RELAY_MAX_NAMED", + "NDF_RELAY_NAME", + "NDF_SV_STATE_DIR", + "NDF_WHOLE_RUNS_DIR", + "XDG_STATE_HOME" + ], + "settings": [ + { + "file": "estimate.json", + "keys": [ + "min_samples", + "records_dir", + "window" + ] + }, + { + "file": "project.json", + "keys": [ + "ndf_policies" + ] + }, + { + "file": "supervise.json", + "keys": [ + "release" + ] + }, + { + "file": "worktree.json", + "keys": [ + "base_branch" + ] + } + ], + "stdin": "text", + "stdout": "step-result", + "writes": [ + "answer-json", + "approved-txt", + "breakdown-md", + "plan-json", + "plan-md", + "present-json", + "presented-json", + "prs-txt", + "request-id-txt", + "request-txt", + "run-log", + "run-pid" + ], + "reads": [], + "exit": { + "0": "成功(status ok)", + "1": "計画が無い・検査に誤り・動いていない・承認したコミットが無い(status stopped)", + "2": "読めない・呼び出しの誤り(status stopped)", + "3": "まだ変化が無い・答えがまだ無い(status stopped)", + "4": "本番のプランが承認ゲートで止まっている(status stopped)", + "10": "配布の依頼を送った・依頼が退けられた(status gate)" + }, + "callers": [ + "plugins/ndf/scripts/lib/parallel_lane.py", + "plugins/ndf/scripts/lib/parallel_plan.py", + "plugins/ndf/scripts/lib/parallel_release.py", + "plugins/ndf/scripts/supervise_lib/paths.py", + "plugins/ndf/skills/development-workflow/references/parallel.md" + ] +} diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json index 198fa829a..b9eefc4ac 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json @@ -19,7 +19,10 @@ "NDF_DEPS_VENV", "NDF_ESTIMATE_DIR", "NDF_MVV_STATE_DIR", + "NDF_PARALLEL_MAX_PLANS", "NDF_PRESENTATION_DIR", + "NDF_RELAY_MAX_NAMED", + "NDF_RELAY_NAME", "NDF_SHARED_CONFIG_DIR", "NDF_STEP_EXTEND_FILE", "NDF_SUPERVISE_CLAUDE", @@ -64,7 +67,17 @@ "stdout": "step-result", "writes": [ "actuals-run-json", + "answer-json", + "approved-txt", + "breakdown-md", + "plan-json", + "plan-md", + "present-json", + "presented-json", + "prs-txt", "report-md", + "request-id-txt", + "request-txt", "state-json" ], "reads": [], diff --git a/plugins/ndf/contracts/env/NDF_PARALLEL_MAX_PLANS.json b/plugins/ndf/contracts/env/NDF_PARALLEL_MAX_PLANS.json new file mode 100644 index 000000000..d91c360d9 --- /dev/null +++ b/plugins/ndf/contracts/env/NDF_PARALLEL_MAX_PLANS.json @@ -0,0 +1,5 @@ +{ + "name": "NDF_PARALLEL_MAX_PLANS", + "set_by": "user", + "summary": "並列の計画で同時に流すプランの合計の上限(#2436)。正の整数でなければ既定の 6(plugins/ndf/scripts/lib/parallel_plan.py)" +} diff --git a/plugins/ndf/contracts/env/NDF_PARALLEL_POLL.json b/plugins/ndf/contracts/env/NDF_PARALLEL_POLL.json new file mode 100644 index 000000000..eb94affcb --- /dev/null +++ b/plugins/ndf/contracts/env/NDF_PARALLEL_POLL.json @@ -0,0 +1,5 @@ +{ + "name": "NDF_PARALLEL_POLL", + "set_by": "user", + "summary": "並列の計画の run が inbox と resume を待つ秒(既定 60。テストが縮める。plugins/ndf/scripts/lib/parallel_run.py)" +} diff --git a/plugins/ndf/contracts/env/NDF_PARALLEL_RELAY.json b/plugins/ndf/contracts/env/NDF_PARALLEL_RELAY.json new file mode 100644 index 000000000..9207a0256 --- /dev/null +++ b/plugins/ndf/contracts/env/NDF_PARALLEL_RELAY.json @@ -0,0 +1,5 @@ +{ + "name": "NDF_PARALLEL_RELAY", + "set_by": "user", + "summary": "並列の計画の run が打つ統括役の CLI の差し替え(テスト用。plugins/ndf/scripts/lib/parallel_run.py)" +} diff --git a/plugins/ndf/contracts/env/NDF_RELAY_MAX_NAMED.json b/plugins/ndf/contracts/env/NDF_RELAY_MAX_NAMED.json new file mode 100644 index 000000000..bf46a7058 --- /dev/null +++ b/plugins/ndf/contracts/env/NDF_RELAY_MAX_NAMED.json @@ -0,0 +1,5 @@ +{ + "name": "NDF_RELAY_MAX_NAMED", + "set_by": "user", + "summary": "名前つきのラッパーの数の上限(#2346)。並列の計画の検査も同じ値を読む(plugins/ndf/scripts/lib/parallel_plan.py・relay_lib/hub.py)" +} diff --git a/plugins/ndf/contracts/files/answer-json.json b/plugins/ndf/contracts/files/answer-json.json new file mode 100644 index 000000000..7dd2564e8 --- /dev/null +++ b/plugins/ndf/contracts/files/answer-json.json @@ -0,0 +1,7 @@ +{ + "name": "answer-json", + "pattern": "answer.json", + "kind": "state", + "place": "main", + "skill_readers": [] +} diff --git a/plugins/ndf/contracts/files/approved-txt.json b/plugins/ndf/contracts/files/approved-txt.json new file mode 100644 index 000000000..50cfaf108 --- /dev/null +++ b/plugins/ndf/contracts/files/approved-txt.json @@ -0,0 +1,7 @@ +{ + "name": "approved-txt", + "pattern": "approved.txt", + "kind": "result", + "place": "state-dir", + "skill_readers": [] +} diff --git a/plugins/ndf/contracts/files/breakdown-md.json b/plugins/ndf/contracts/files/breakdown-md.json new file mode 100644 index 000000000..a18feedc2 --- /dev/null +++ b/plugins/ndf/contracts/files/breakdown-md.json @@ -0,0 +1,7 @@ +{ + "name": "breakdown-md", + "pattern": "breakdown.md", + "kind": "report", + "place": "main", + "skill_readers": [] +} diff --git a/plugins/ndf/contracts/files/plan-json.json b/plugins/ndf/contracts/files/plan-json.json new file mode 100644 index 000000000..81f56f6f2 --- /dev/null +++ b/plugins/ndf/contracts/files/plan-json.json @@ -0,0 +1,7 @@ +{ + "name": "plan-json", + "pattern": "plan.json", + "kind": "record", + "place": "main", + "skill_readers": [] +} diff --git a/plugins/ndf/contracts/files/plan-md.json b/plugins/ndf/contracts/files/plan-md.json new file mode 100644 index 000000000..c7143798a --- /dev/null +++ b/plugins/ndf/contracts/files/plan-md.json @@ -0,0 +1,7 @@ +{ + "name": "plan-md", + "pattern": "plan.md", + "kind": "report", + "place": "main", + "skill_readers": [] +} diff --git a/plugins/ndf/contracts/files/present-json.json b/plugins/ndf/contracts/files/present-json.json new file mode 100644 index 000000000..75733844b --- /dev/null +++ b/plugins/ndf/contracts/files/present-json.json @@ -0,0 +1,7 @@ +{ + "name": "present-json", + "pattern": "present.json", + "kind": "record", + "place": "main", + "skill_readers": [] +} diff --git a/plugins/ndf/contracts/files/presented-json.json b/plugins/ndf/contracts/files/presented-json.json new file mode 100644 index 000000000..2b616e9a0 --- /dev/null +++ b/plugins/ndf/contracts/files/presented-json.json @@ -0,0 +1,7 @@ +{ + "name": "presented-json", + "pattern": "presented.json", + "kind": "result", + "place": "main", + "skill_readers": [] +} diff --git a/plugins/ndf/contracts/files/prs-txt.json b/plugins/ndf/contracts/files/prs-txt.json new file mode 100644 index 000000000..9d25e4108 --- /dev/null +++ b/plugins/ndf/contracts/files/prs-txt.json @@ -0,0 +1,7 @@ +{ + "name": "prs-txt", + "pattern": "prs.txt", + "kind": "result", + "place": "state-dir", + "skill_readers": [] +} diff --git a/plugins/ndf/contracts/files/request-id-txt.json b/plugins/ndf/contracts/files/request-id-txt.json new file mode 100644 index 000000000..67d522535 --- /dev/null +++ b/plugins/ndf/contracts/files/request-id-txt.json @@ -0,0 +1,7 @@ +{ + "name": "request-id-txt", + "pattern": "request-id.txt", + "kind": "state", + "place": "state-dir", + "skill_readers": [] +} diff --git a/plugins/ndf/contracts/files/request-txt.json b/plugins/ndf/contracts/files/request-txt.json new file mode 100644 index 000000000..3b3d1e5b1 --- /dev/null +++ b/plugins/ndf/contracts/files/request-txt.json @@ -0,0 +1,7 @@ +{ + "name": "request-txt", + "pattern": "request.txt", + "kind": "result", + "place": "state-dir", + "skill_readers": [] +} diff --git a/plugins/ndf/contracts/files/run-log.json b/plugins/ndf/contracts/files/run-log.json new file mode 100644 index 000000000..5d82e2125 --- /dev/null +++ b/plugins/ndf/contracts/files/run-log.json @@ -0,0 +1,7 @@ +{ + "name": "run-log", + "pattern": "run.log", + "kind": "report", + "place": "main", + "skill_readers": [] +} diff --git a/plugins/ndf/contracts/files/run-pid.json b/plugins/ndf/contracts/files/run-pid.json new file mode 100644 index 000000000..7b4700315 --- /dev/null +++ b/plugins/ndf/contracts/files/run-pid.json @@ -0,0 +1,7 @@ +{ + "name": "run-pid", + "pattern": "run.pid", + "kind": "state", + "place": "main", + "skill_readers": [] +} diff --git a/plugins/ndf/contracts/index.md b/plugins/ndf/contracts/index.md index ccb076c2c..d4d2b8c14 100644 --- a/plugins/ndf/contracts/index.md +++ b/plugins/ndf/contracts/index.md @@ -11,19 +11,19 @@ | `backlog.json` | worktree | team | `plugins/ndf/skills/backlog-refinement/scripts/upkeep_rank_cmd.py:read_backlog_decl` | — | | `code-metrics.json` | worktree | team | `plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/codemetrics.py:load_config` | — | | `context.json` | worktree | team | `plugins/ndf/scripts/lib/context_policy.py:read_setting` | — | -| `estimate.json` | worktree | team | `plugins/ndf/scripts/lib/estimate_records.py:load_estimate_decl` | `plugins/ndf/scripts/estimate.py(records_dir)`
`plugins/ndf/scripts/release-steps.py(records_dir)`
`plugins/ndf/scripts/sprint-state.py(records_dir)`
`plugins/ndf/scripts/supervise.py(min_samples, records_dir, window)`
`plugins/ndf/scripts/test-run.py(records_dir)`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py(records_dir)`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py(records_dir)`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py(records_dir)`
`plugins/ndf/skills/cross-review/scripts/drive.py(records_dir)` | +| `estimate.json` | worktree | team | `plugins/ndf/scripts/lib/estimate_records.py:load_estimate_decl` | `plugins/ndf/scripts/estimate.py(records_dir)`
`plugins/ndf/scripts/parallel.py(min_samples, records_dir, window)`
`plugins/ndf/scripts/release-steps.py(records_dir)`
`plugins/ndf/scripts/sprint-state.py(records_dir)`
`plugins/ndf/scripts/supervise.py(min_samples, records_dir, window)`
`plugins/ndf/scripts/test-run.py(records_dir)`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py(records_dir)`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py(records_dir)`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py(records_dir)`
`plugins/ndf/skills/cross-review/scripts/drive.py(records_dir)` | | `glossary.json` | worktree | team | `plugins/ndf/scripts/glossary.py:load_declaration` | — | | `instructions.json` | worktree | team | `plugins/ndf/scripts/instructions_lib/declaration.py:load` | — | | `mvv.json` | worktree | team | `plugins/ndf/scripts/lib/project_mvv.py:decl_paths` | — | | `mvv.md` | worktree | team | `plugins/ndf/scripts/lib/project_mvv.py:decl_paths` | — | | `pace.json` | worktree | team | `plugins/ndf/scripts/lib/pace.py:_read_pace_decl`
`plugins/ndf/scripts/lib/pace.py:read_pace` | `plugins/ndf/scripts/mvv-gate.py(boundary_paths)`
`plugins/ndf/scripts/sprint-state.py(auto, enabled, verify)` | -| `project.json` | worktree(delivery は main) | team | `plugins/ndf/scripts/lib/project_decl.py:read_project_decl_checked`
`plugins/ndf/scripts/lib/project_decl.py:read_project_decl` | `plugins/ndf/scripts/doc-lint.py(ndf_policies)`
`plugins/ndf/scripts/estimate.py(ndf_policies)`
`plugins/ndf/scripts/gate-rejections.py(ndf_policies)`
`plugins/ndf/scripts/hook.py(ndf_policies)`
`plugins/ndf/scripts/merged-steps.py(ndf_policies)`
`plugins/ndf/scripts/mvv-gate.py(ndf_policies)`
`plugins/ndf/scripts/project-decl.py(ndf_policies)`
`plugins/ndf/scripts/project-mvv.py(ndf_policies)`
`plugins/ndf/scripts/release-steps.py(ndf_policies)`
`plugins/ndf/scripts/release-verification-steps.py(ndf_policies)`
`plugins/ndf/scripts/sprint-state.py(ndf_policies)`
`plugins/ndf/scripts/supervise.py(ndf_policies)`
`plugins/ndf/scripts/test-run.py(ndf_policies, test)`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py(materials, ndf_policies)`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py(ndf_policies, test)`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py(ndf_policies, test)`
`plugins/ndf/skills/cross-review/scripts/drive.py(ndf_policies)`
`plugins/ndf/skills/cross-review/scripts/state.py(ndf_policies)`
`plugins/ndf/skills/fix/scripts/fix-steps.py(ndf_policies)` | +| `project.json` | worktree(delivery は main) | team | `plugins/ndf/scripts/lib/project_decl.py:read_project_decl_checked`
`plugins/ndf/scripts/lib/project_decl.py:read_project_decl` | `plugins/ndf/scripts/doc-lint.py(ndf_policies)`
`plugins/ndf/scripts/estimate.py(ndf_policies)`
`plugins/ndf/scripts/gate-rejections.py(ndf_policies)`
`plugins/ndf/scripts/hook.py(ndf_policies)`
`plugins/ndf/scripts/merged-steps.py(ndf_policies)`
`plugins/ndf/scripts/mvv-gate.py(ndf_policies)`
`plugins/ndf/scripts/parallel.py(ndf_policies)`
`plugins/ndf/scripts/project-decl.py(ndf_policies)`
`plugins/ndf/scripts/project-mvv.py(ndf_policies)`
`plugins/ndf/scripts/release-steps.py(ndf_policies)`
`plugins/ndf/scripts/release-verification-steps.py(ndf_policies)`
`plugins/ndf/scripts/sprint-state.py(ndf_policies)`
`plugins/ndf/scripts/supervise.py(ndf_policies)`
`plugins/ndf/scripts/test-run.py(ndf_policies, test)`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py(materials, ndf_policies)`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py(ndf_policies, test)`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py(ndf_policies, test)`
`plugins/ndf/skills/cross-review/scripts/drive.py(ndf_policies)`
`plugins/ndf/skills/cross-review/scripts/state.py(ndf_policies)`
`plugins/ndf/skills/fix/scripts/fix-steps.py(ndf_policies)` | | `projects.json` | worktree | team | `plugins/ndf/scripts/lib/projects-common.sh` | — | | `release.json` | worktree | team | `plugins/ndf/scripts/release-steps.py:load_steps` | — | | `review.json` | worktree | team | `plugins/ndf/scripts/lib/review_criteria_decl.py:load` | — | | `runtimes.json` | main | team | `plugins/ndf/scripts/lib/runtime_policy.py:find` | — | -| `supervise.json` | worktree | team | `plugins/ndf/scripts/supervise_lib/decl.py:read_decl`
`plugins/ndf/scripts/lib/delivery.py:_read_supervise` | `plugins/ndf/scripts/estimate.py(release)`
`plugins/ndf/scripts/hook.py(release)`
`plugins/ndf/scripts/merged-steps.py(release)`
`plugins/ndf/scripts/release-steps.py(release)`
`plugins/ndf/scripts/release-verification-steps.py(release)`
`plugins/ndf/scripts/sprint-state.py(release)`
`plugins/ndf/scripts/supervise.py(release, slow, test)` | -| `worktree.json` | worktree(production_branch は main) | team | `plugins/ndf/scripts/lib/repo.py:read_worktree_decl`
`plugins/ndf/scripts/supervise_lib/decl.py:read_decl` | `plugins/ndf/scripts/acceptance-check.py(base_branch, tool_paths, version)`
`plugins/ndf/scripts/check-trigger.py(base_branch)`
`plugins/ndf/scripts/design-match.py(base_branch)`
`plugins/ndf/scripts/doc-lint.py(base_branch)`
`plugins/ndf/scripts/estimate.py(base_branch)`
`plugins/ndf/scripts/gate-rejections.py(base_branch)`
`plugins/ndf/scripts/handoff.py(base_branch)`
`plugins/ndf/scripts/hook.py(base_branch)`
`plugins/ndf/scripts/merged-steps.py(base_branch, tool_paths, version)`
`plugins/ndf/scripts/mvv-gate.py(base_branch)`
`plugins/ndf/scripts/parallel-measure.py(base_branch, tool_paths, version)`
`plugins/ndf/scripts/plan-to-spec-steps.py(base_branch)`
`plugins/ndf/scripts/pr-steps.py(base_branch, tool_paths, version)`
`plugins/ndf/scripts/project-decl.py(base_branch)`
`plugins/ndf/scripts/project-mvv.py(base_branch)`
`plugins/ndf/scripts/release-steps.py(base_branch, tool_paths, version)`
`plugins/ndf/scripts/release-verification-steps.py(base_branch)`
`plugins/ndf/scripts/spec-copy.py(base_branch, tool_paths, version)`
`plugins/ndf/scripts/sprint-close.py(base_branch, tool_paths, version)`
`plugins/ndf/scripts/sprint-state.py(base_branch)`
`plugins/ndf/scripts/supervise.py(base_branch)`
`plugins/ndf/scripts/test-run.py(base_branch)`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py(base_branch)`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py(base_branch, tool_paths, version)`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py(base_branch, tool_paths, version)`
`plugins/ndf/skills/cross-review/scripts/drive.py(base_branch, tool_paths, version)`
`plugins/ndf/skills/cross-review/scripts/state.py(base_branch, tool_paths, version)`
`plugins/ndf/skills/fix/scripts/fix-steps.py(base_branch, tool_paths, version)`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py(base_branch)` | +| `supervise.json` | worktree | team | `plugins/ndf/scripts/supervise_lib/decl.py:read_decl`
`plugins/ndf/scripts/lib/delivery.py:_read_supervise` | `plugins/ndf/scripts/estimate.py(release)`
`plugins/ndf/scripts/hook.py(release)`
`plugins/ndf/scripts/merged-steps.py(release)`
`plugins/ndf/scripts/parallel.py(release)`
`plugins/ndf/scripts/release-steps.py(release)`
`plugins/ndf/scripts/release-verification-steps.py(release)`
`plugins/ndf/scripts/sprint-state.py(release)`
`plugins/ndf/scripts/supervise.py(release, slow, test)` | +| `worktree.json` | worktree(production_branch は main) | team | `plugins/ndf/scripts/lib/repo.py:read_worktree_decl`
`plugins/ndf/scripts/supervise_lib/decl.py:read_decl` | `plugins/ndf/scripts/acceptance-check.py(base_branch, tool_paths, version)`
`plugins/ndf/scripts/check-trigger.py(base_branch)`
`plugins/ndf/scripts/design-match.py(base_branch)`
`plugins/ndf/scripts/doc-lint.py(base_branch)`
`plugins/ndf/scripts/estimate.py(base_branch)`
`plugins/ndf/scripts/gate-rejections.py(base_branch)`
`plugins/ndf/scripts/handoff.py(base_branch)`
`plugins/ndf/scripts/hook.py(base_branch)`
`plugins/ndf/scripts/merged-steps.py(base_branch, tool_paths, version)`
`plugins/ndf/scripts/mvv-gate.py(base_branch)`
`plugins/ndf/scripts/parallel-measure.py(base_branch, tool_paths, version)`
`plugins/ndf/scripts/parallel.py(base_branch)`
`plugins/ndf/scripts/plan-to-spec-steps.py(base_branch)`
`plugins/ndf/scripts/pr-steps.py(base_branch, tool_paths, version)`
`plugins/ndf/scripts/project-decl.py(base_branch)`
`plugins/ndf/scripts/project-mvv.py(base_branch)`
`plugins/ndf/scripts/release-steps.py(base_branch, tool_paths, version)`
`plugins/ndf/scripts/release-verification-steps.py(base_branch)`
`plugins/ndf/scripts/spec-copy.py(base_branch, tool_paths, version)`
`plugins/ndf/scripts/sprint-close.py(base_branch, tool_paths, version)`
`plugins/ndf/scripts/sprint-state.py(base_branch)`
`plugins/ndf/scripts/supervise.py(base_branch)`
`plugins/ndf/scripts/test-run.py(base_branch)`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py(base_branch)`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py(base_branch, tool_paths, version)`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py(base_branch, tool_paths, version)`
`plugins/ndf/skills/cross-review/scripts/drive.py(base_branch, tool_paths, version)`
`plugins/ndf/skills/cross-review/scripts/state.py(base_branch, tool_paths, version)`
`plugins/ndf/skills/fix/scripts/fix-steps.py(base_branch, tool_paths, version)`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py(base_branch)` | | `worktree.local.json` | main | personal | `plugins/ndf/scripts/lib/worktree-declaration.sh` | — | ## 受け渡しのファイル @@ -31,9 +31,12 @@ | 名前 | 型 | 種類 | 置き場 | 書くエントリポイント | 読むエントリポイント・Skill | | --- | --- | --- | --- | --- | --- | | `actuals-run-json` | `actuals-run.json` | result | state-dir | `plugins/ndf/scripts/supervise.py` | — | +| `answer-json` | `answer.json` | state | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | | `applied-json` | `*.applied.json` | state | state-dir | `plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | — | | `apply-json` | `apply.json` | result | tmp | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | +| `approved-txt` | `approved.txt` | result | state-dir | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | | `before-json` | `before.json` | record | main | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | +| `breakdown-md` | `breakdown.md` | report | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | | `candidates-json` | `candidates.json` | result | tmp | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | | `cost-estimates-json` | `*.cost-estimates.json` | result | state-dir | `plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | — | | `counts-json` | `counts.json` | result | tmp | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | @@ -43,10 +46,19 @@ | `laps-md` | `laps.md` | report | main | `plugins/ndf/scripts/sprint-state.py` | — | | `loop-json` | `loop.json` | result | main | `plugins/ndf/scripts/sprint-state.py` | — | | `measures-json` | `measures.json` | result | tmp | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | +| `plan-json` | `plan.json` | record | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | +| `plan-md` | `plan.md` | report | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | +| `present-json` | `present.json` | record | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | +| `presented-json` | `presented.json` | result | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | +| `prs-txt` | `prs.txt` | result | state-dir | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | | `rank-json` | `rank.json` | result | tmp | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | | `report-md` | `report.md` | report | state-dir | `plugins/ndf/scripts/supervise.py` | — | +| `request-id-txt` | `request-id.txt` | state | state-dir | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | +| `request-txt` | `request.txt` | result | state-dir | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | | `research-calls-jsonl` | `calls.jsonl` | record | main | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | | `research-item-json` | `*--*.item.json` | record | main | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | +| `run-log` | `run.log` | report | main | `plugins/ndf/scripts/parallel.py` | — | +| `run-pid` | `run.pid` | state | main | `plugins/ndf/scripts/parallel.py` | — | | `snapshot-json` | `snapshot.json` | record | tmp | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | | `state-json` | `state.json` | state | state-dir | `plugins/ndf/scripts/supervise.py` | — | | `target-json` | `target.json` | result | tmp | `plugins/ndf/skills/pr-review/scripts/pr-review-steps.py` | — | @@ -71,8 +83,8 @@ | `CROSS_REVIEW_TMP_DIR` | runtime | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | | `DEBUG_SLACK_NOTIFY` | runtime | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/wait-notify.py` | | `DEVBASE_SHELLRC_DIR` | runtime | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | -| `GH_TOKEN` | runtime | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/refactor-scope.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py` | -| `GITHUB_TOKEN` | ci | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/refactor-scope.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py` | +| `GH_TOKEN` | runtime | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/refactor-scope.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py` | +| `GITHUB_TOKEN` | ci | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/refactor-scope.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py` | | `GIT_REPO` | runtime | `plugins/ndf/scripts/wait-notify.py` | | `HOME` | runtime | `plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/install-official-skills.sh`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/resolve.sh`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/statusline-switch.sh`
`plugins/ndf/scripts/statusline.sh`
`plugins/ndf/scripts/token-guard.sh`
`plugins/ndf/scripts/worktree-guard.sh`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | | `KIRO_SESSION_ID` | runtime | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/wait-notify.py` | @@ -87,9 +99,9 @@ | `NDF_CONTEXT_LIMIT` | user | `plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | | `NDF_CONTEXT_POLICY` | user | `plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | | `NDF_CRITIQUE_PRINT_TIMEOUT` | user | `plugins/ndf/skills/cross-review/scripts/critique.sh` | -| `NDF_DEPS_REEXEC` | user | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/ask.py`
`plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/design-match.py`
`plugins/ndf/scripts/doc-lint.py`
`plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/glossary.py`
`plugins/ndf/scripts/handoff.py`
`plugins/ndf/scripts/hook-env.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/instructions-check.py`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/plan-to-spec-steps.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/project-decl.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/release-verification-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/stage-skills.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/scripts/usage-report.py`
`plugins/ndf/scripts/wait-notify.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/google-workspace/scripts/gws-check.py`
`plugins/ndf/skills/google-workspace/scripts/slides-thumbnails.py`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | -| `NDF_DEPS_VENV` | user | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/ask.py`
`plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/design-match.py`
`plugins/ndf/scripts/doc-lint.py`
`plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/glossary.py`
`plugins/ndf/scripts/handoff.py`
`plugins/ndf/scripts/hook-env.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/instructions-check.py`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/plan-to-spec-steps.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/project-decl.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/release-verification-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/stage-skills.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/scripts/usage-report.py`
`plugins/ndf/scripts/wait-notify.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/google-workspace/scripts/gws-check.py`
`plugins/ndf/skills/google-workspace/scripts/slides-thumbnails.py`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | -| `NDF_ESTIMATE_DIR` | user | `plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py` | +| `NDF_DEPS_REEXEC` | user | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/ask.py`
`plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/design-match.py`
`plugins/ndf/scripts/doc-lint.py`
`plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/glossary.py`
`plugins/ndf/scripts/handoff.py`
`plugins/ndf/scripts/hook-env.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/instructions-check.py`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/plan-to-spec-steps.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/project-decl.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/release-verification-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/stage-skills.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/scripts/usage-report.py`
`plugins/ndf/scripts/wait-notify.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/google-workspace/scripts/gws-check.py`
`plugins/ndf/skills/google-workspace/scripts/slides-thumbnails.py`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | +| `NDF_DEPS_VENV` | user | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/ask.py`
`plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/design-match.py`
`plugins/ndf/scripts/doc-lint.py`
`plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/glossary.py`
`plugins/ndf/scripts/handoff.py`
`plugins/ndf/scripts/hook-env.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/instructions-check.py`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/plan-to-spec-steps.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/project-decl.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/release-verification-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/stage-skills.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/scripts/usage-report.py`
`plugins/ndf/scripts/wait-notify.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/google-workspace/scripts/gws-check.py`
`plugins/ndf/skills/google-workspace/scripts/slides-thumbnails.py`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | +| `NDF_ESTIMATE_DIR` | user | `plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py` | | `NDF_EXTERNAL_AI_TMP_DIR` | user | `plugins/ndf/skills/external-ai/scripts/external-ai.py` | | `NDF_HOOK_ENV` | user | `plugins/ndf/scripts/worktree-session.sh` | | `NDF_HOOK_PYTHON` | user | `plugins/ndf/scripts/hook-env.py`
`plugins/ndf/scripts/token-guard.sh`
`plugins/ndf/scripts/wait-notify.py`
`plugins/ndf/scripts/worktree-guard.sh` | @@ -99,13 +111,17 @@ | `NDF_LOOP_EXCLUDE` | runtime | `plugins/ndf/scripts/sprint-state.py` | | `NDF_MVV_CLAUDE` | user | `plugins/ndf/scripts/mvv-gate.py` | | `NDF_MVV_STATE_DIR` | user | `plugins/ndf/scripts/ask.py`
`plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | -| `NDF_PRESENTATION_DIR` | user | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/ask.py`
`plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/design-match.py`
`plugins/ndf/scripts/doc-lint.py`
`plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/glossary.py`
`plugins/ndf/scripts/handoff.py`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/plan-to-spec-steps.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/project-decl.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/release-verification-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/stage-skills.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/google-workspace/scripts/gws-check.py`
`plugins/ndf/skills/google-workspace/scripts/slides-thumbnails.py`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py` | +| `NDF_PARALLEL_MAX_PLANS` | user | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | +| `NDF_PARALLEL_POLL` | user | `plugins/ndf/scripts/parallel.py` | +| `NDF_PARALLEL_RELAY` | user | `plugins/ndf/scripts/parallel.py` | +| `NDF_PRESENTATION_DIR` | user | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/ask.py`
`plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/design-match.py`
`plugins/ndf/scripts/doc-lint.py`
`plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/glossary.py`
`plugins/ndf/scripts/handoff.py`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/plan-to-spec-steps.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/project-decl.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/release-verification-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/stage-skills.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/google-workspace/scripts/gws-check.py`
`plugins/ndf/skills/google-workspace/scripts/slides-thumbnails.py`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py` | | `NDF_PROGRESS_LOCK_TIMEOUT` | user | `plugins/ndf/scripts/progress-record.sh` | | `NDF_RELAY` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | | `NDF_RELAY_CLAUDE` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | | `NDF_RELAY_DEPTH` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | -| `NDF_RELAY_DIR` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py` | -| `NDF_RELAY_NAME` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | +| `NDF_RELAY_DIR` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py` | +| `NDF_RELAY_MAX_NAMED` | user | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | +| `NDF_RELAY_NAME` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/supervise.py` | | `NDF_RELAY_REEXEC` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | | `NDF_RELAY_SPAWNED` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | | `NDF_RELAY_TMUX` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | @@ -123,12 +139,12 @@ | `NDF_SUPERVISE_LIMIT_SLEEP` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | | `NDF_SUPERVISE_MODEL` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | | `NDF_SUPERVISOR_CUT_RATIO` | user | `plugins/ndf/scripts/hook.py` | -| `NDF_SV_STATE_DIR` | user | `plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | +| `NDF_SV_STATE_DIR` | user | `plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | | `NDF_UPKEEP_NO_SLEEP` | user | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | | `NDF_UPKEEP_STATE_DIR` | user | `plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | | `NDF_USAGE_KIND` | user | `plugins/ndf/skills/external-ai/scripts/external-ai.py` | | `NDF_USAGE_SOURCE` | user | `plugins/ndf/skills/external-ai/scripts/external-ai.py` | -| `NDF_WHOLE_RUNS_DIR` | user | `plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py` | +| `NDF_WHOLE_RUNS_DIR` | user | `plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py` | | `NDF_WORKTREE_BASE` | user | `plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py` | | `PATH` | runtime | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/release-verification-steps.py` | | `REDMINE_URL` | runtime | `plugins/ndf/scripts/wait-notify.py` | @@ -151,5 +167,5 @@ | `WT_WORKTREE_DIR` | runtime | `plugins/ndf/scripts/worktree-setup.sh` | | `XDG_CACHE_HOME` | runtime | `plugins/ndf/scripts/install-official-skills.sh` | | `XDG_DATA_HOME` | runtime | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | -| `XDG_STATE_HOME` | runtime | `plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/statusline.sh`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/scripts/wait-notify.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | +| `XDG_STATE_HOME` | runtime | `plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/statusline.sh`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/scripts/wait-notify.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | | `ZDOTDIR` | runtime | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py index 43d8614de..6aa8ba9e6 100644 --- a/plugins/ndf/scripts/lib/parallel_lane.py +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -106,13 +106,13 @@ def released_version(main: Path, name: str, prs: list[int]) -> Optional[str]: def lane_request_id(state: Path, lane: str) -> str: - """依頼の ID。最初の実行で `<レーン名>-r<乱数 12 桁>` を作って `work/request-id` へ書き、打ち直しでは読む。""" - p = Path(state) / "work" / "request-id" + """依頼の ID。最初の実行で `<レーン名>-r<乱数 12 桁>` を作って `work/request-id.txt` へ書き、打ち直しでは読む。""" + p = Path(state) / "work" / "request-id.txt" if p.is_file() and p.read_text(encoding="utf-8").strip(): return p.read_text(encoding="utf-8").strip() p.parent.mkdir(parents=True, exist_ok=True) rid = f"{lane}-r{secrets.randbelow(10**12):012d}" - p.write_text(rid + "\n", encoding="utf-8") + (Path(state) / "work" / "request-id.txt").write_text(rid + "\n", encoding="utf-8") return rid @@ -176,6 +176,8 @@ def read_answer(main: Path, name: str, rid: str, timeout: float = 0) -> tuple[Op time.sleep(min(5.0, max(0.2, deadline - time.monotonic()))) -def write_out(out: Path, data: dict) -> None: - Path(out).parent.mkdir(parents=True, exist_ok=True) - Path(out).write_text(json.dumps(data, ensure_ascii=False) + "\n", encoding="utf-8") +def write_request(state: Path, data: dict) -> Path: + """依頼の記録をプランの状態ディレクトリの `work/request.txt` へ書き、そのパスを返す(`parallel.py await --from` が読む)。""" + (Path(state) / "work").mkdir(parents=True, exist_ok=True) + (Path(state) / "work" / "request.txt").write_text(json.dumps(data, ensure_ascii=False) + "\n", encoding="utf-8") + return Path(state) / "work" / "request.txt" diff --git a/plugins/ndf/scripts/lib/parallel_plan.py b/plugins/ndf/scripts/lib/parallel_plan.py index e14ffe283..7f4023037 100644 --- a/plugins/ndf/scripts/lib/parallel_plan.py +++ b/plugins/ndf/scripts/lib/parallel_plan.py @@ -28,9 +28,12 @@ PARALLEL_DIR = Path(".ndf") / "parallel" -def _positive_env(name: str, default: int) -> tuple[int, Optional[str]]: - """環境変数の正の整数。正の整数でなければ既定と、そのことを書いた 1 文を返す(I2)。""" - raw = os.environ.get(name) +MAX_PLANS_ENV = "NDF_PARALLEL_MAX_PLANS" +MAX_NAMED_ENV = "NDF_RELAY_MAX_NAMED" + + +def _positive_count(name: str, raw: Optional[str], default: int) -> tuple[int, Optional[str]]: + """環境変数の値の正の整数。正の整数でなければ既定と、そのことを書いた 1 文を返す(I2)。""" if raw is None or raw == "": return default, None try: @@ -43,11 +46,11 @@ def _positive_env(name: str, default: int) -> tuple[int, Optional[str]]: def max_plans() -> tuple[int, Optional[str]]: - return _positive_env("NDF_PARALLEL_MAX_PLANS", DEFAULT_MAX_PLANS) + return _positive_count(MAX_PLANS_ENV, os.environ.get(MAX_PLANS_ENV), DEFAULT_MAX_PLANS) def named_limit() -> tuple[int, Optional[str]]: - return _positive_env("NDF_RELAY_MAX_NAMED", DEFAULT_MAX_NAMED) + return _positive_count(MAX_NAMED_ENV, os.environ.get(MAX_NAMED_ENV), DEFAULT_MAX_NAMED) class LoopInput(BaseModel): @@ -85,6 +88,17 @@ def _name(cls, v: str) -> str: raise PydanticCustomError("I1", "レーン名が一時レーンの名前の形(-rel と数字で終わる)に当たる: {name}", {"name": v}) return v + @field_validator("handoff") + @classmethod + def _handoff(cls, v: Optional[str]) -> Optional[str]: + import handoff # 引継ぎ文書の名の規則は handoff.py が持つ + + if v is not None and (not handoff.NAME_RE.match(v) or v.endswith(handoff.HISTORY_SUFFIX)): + raise PydanticCustomError( + "I5", "引継ぎ文書の名の形が違う({pattern}): {name}", {"pattern": handoff.NAME_RE.pattern, "name": v} + ) + return v + def handoff_name(self) -> str: """引継ぎ文書の名(ループなら `loop-<ループ名>`、スプリントなら `issue-<最初の番号>`)。""" if self.handoff: @@ -315,26 +329,26 @@ def render_handoff(plan: ParallelPlan, lane: Lane, plan_md: Path) -> str: def write_all(main: Path, plan: ParallelPlan, raw: dict) -> list[Path]: - """`plan.json`・`plan.md`・引継ぎ文書を書く。在る引継ぎ文書・在る計画があれば 1 つも書かずに FileExistsError(I5)。""" + """`plan.json`・`plan.md`・引継ぎ文書を書く。在る引継ぎ文書・在る計画があれば 1 つも書かずに FileExistsError(I5)。 + + 引継ぎ文書の置き場と書き込みは handoff.py(`paths_of`・`write_doc`)に任せる。""" import json + import handoff + d = plan_dir(main, plan.name) - handoffs = Path(main) / ".ndf" / "handoff" - targets = {ln.name: handoffs / f"{ln.handoff_name()}.md" for ln in plan.lanes} + targets = {ln.name: handoff.paths_of(main, ln.handoff_name())[0] for ln in plan.lanes} existing = [str(p) for p in [d / "plan.json", *targets.values()] if p.exists()] if existing: raise FileExistsError(", ".join(existing)) d.mkdir(parents=True, exist_ok=True) - handoffs.mkdir(parents=True, exist_ok=True) data = dict(raw) data["lanes"] = [{**dict(r), "handoff": ln.handoff_name()} for r, ln in zip(raw["lanes"], plan.lanes)] - written = [d / "plan.json", d / "plan.md"] (d / "plan.json").write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") (d / "plan.md").write_text(render_md(plan), encoding="utf-8") for ln in plan.lanes: - targets[ln.name].write_text(render_handoff(plan, ln, d / "plan.md"), encoding="utf-8") - written.append(targets[ln.name]) - return written + handoff.write_doc(targets[ln.name], render_handoff(plan, ln, d / "plan.md")) + return [d / "plan.json", d / "plan.md", *targets.values()] def manual_commands(plan: ParallelPlan) -> list[str]: diff --git a/plugins/ndf/scripts/lib/parallel_release.py b/plugins/ndf/scripts/lib/parallel_release.py index 0acd79e2c..17c6ac5f8 100644 --- a/plugins/ndf/scripts/lib/parallel_release.py +++ b/plugins/ndf/scripts/lib/parallel_release.py @@ -20,7 +20,7 @@ def moved(presented: str, head: str) -> bool: - """承認の答えの時点の `origin/develop` の先頭が、提示したコミットから進んでいるか(I14)。""" + """承認の答えの時点のベースブランチの先頭が、提示したコミットから進んでいるか(I14)。""" return bool(head) and head.strip() != presented.strip() @@ -95,13 +95,15 @@ def present( plan: pp.ParallelPlan, approval: Path, requests: list[dict], - out_prs: Path, - presented_json: Path, + d: Path, first: Optional[dict] = None, closes_of: Callable = closes_by_pr, ) -> dict: - """承認資料から提示したコミットと含む Pull Request を読み、レーンへ対応づけて `presented.json` と `--out` を書く。 + """承認資料から提示したコミットと含む Pull Request を読み、レーンへ対応づける。 + 書くのは提示の置き場 `d`(`releases/<連番>/<提示>/`)の決まった名前だけである: `presented.json`、承認資料へ足す + 内訳 `breakdown.md`、提示のプランの状態ディレクトリの `present/work/prs.txt`・`present/work/approved.txt` + (本番のプランが `{queue_prs:present}`・`{queue_approved:present}` で読む)。承認資料へ内訳を足すのはプランのコマンドである。 `first` は出し直しのときの提示 1 の `presented.json`(加わった範囲を載せる)。承認資料に承認したコミットが無ければ ValueError。""" import approved_commit from release_lib import included @@ -115,12 +117,12 @@ def present( extra = [n for n in prs if n not in set(first.get("prs") or [])] added = map_lanes(plan, extra, closes, requests) data = {"sha": sha, "prs": prs, "lanes": mapped["lanes"], "orphans": mapped["orphans"], "added": added} - Path(presented_json).parent.mkdir(parents=True, exist_ok=True) - Path(presented_json).write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - Path(out_prs).parent.mkdir(parents=True, exist_ok=True) - Path(out_prs).write_text(" ".join(str(n) for n in prs) + "\n", encoding="utf-8") - with open(approval, "a", encoding="utf-8") as f: - f.write(breakdown_md(mapped, added)) + d = Path(d) + (d / "present" / "work").mkdir(parents=True, exist_ok=True) + (d / "presented.json").write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + (d / "breakdown.md").write_text(breakdown_md(mapped, added), encoding="utf-8") + (d / "present" / "work" / "prs.txt").write_text(" ".join(str(n) for n in prs) + "\n", encoding="utf-8") + (d / "present" / "work" / "approved.txt").write_text(sha + "\n", encoding="utf-8") return data @@ -149,9 +151,8 @@ def build_plans(main: Path, name: str, seq: int, k: int, version: str, builder: present_plan, release_plan = builder(main, name, d, version) (d / "present.json").write_text(json.dumps(present_plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") (d / "release.json").write_text(json.dumps(release_plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - answer = d / "answer.json" - if not answer.exists(): - answer.write_text(json.dumps({"gates": []}) + "\n", encoding="utf-8") + if not (d / "answer.json").exists(): + (d / "answer.json").write_text(json.dumps({"gates": []}) + "\n", encoding="utf-8") return d @@ -213,7 +214,7 @@ def plan_release_request(a, repo: str, plan_name: str, lane: str, prs: list[str] f"{PARALLEL_PY} request {shlex.quote(plan_name)} --lane {shlex.quote(lane)}" + (f" --prs {pr_args}" if pr_args else "") + (f" --issues {issues}" if issues else "") - + " --state {state_dir} --out {state_dir}/work/request.txt" + + " --state {state_dir}" ) return with_record( { @@ -280,15 +281,14 @@ def plan_parallel_release(main, name: str, d, version: str) -> tuple[dict, dict] present_state = d / "present" # 提示のプランの状態ディレクトリ(`state_dir_of` の置き場と同じ) facts = _dev_approval_steps(ns, repo, approval, "0", ",".join((ns.release or {}).get("runtimes") or [])) facts[-1]["next"] = "present" - first = d.parent / "1" / "presented.json" present = { "id": "present", "type": "run", "stage": "配布", "timeout": 300, "cwd": repo, - "cmd": f"{PARALLEL_PY} present {shlex.quote(name)} {seq} {k} --approval {approval} --out {{state_dir}}/work/prs.txt" - + (f" --first {shlex.quote(str(first))}" if k != "1" else ""), + "cmd": f"{PARALLEL_PY} present {shlex.quote(name)} {seq} {k} --approval {approval}" + + f" && cat {shlex.quote(str(d / 'breakdown.md'))} >> {approval}", "next": "mvv", } gate = present_gate_steps(str(state), repo, approval, "$(cat {state_dir}/work/prs.txt)", "standard", str(present_state), str(state)) diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 063d420fd..f4745886d 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -55,24 +55,46 @@ def relay(main: Path, *args: str) -> tuple[int, dict]: return got.returncode, {} -def git_head(main: Path, ref: str = "origin/develop") -> str: +def base_tracking_ref(main: Path) -> str: + """ベースブランチ(`.ndf/worktree.json` の `base_branch` → 既定ブランチ)の `origin/<名>`。決まらなければ空。""" + import repo + + b = repo.base_branch(main) + return f"origin/{b}" if b else "" + + +def release_plugin(main: Path) -> str: + """配るプラグイン(`.ndf/supervise.json` の `release.plugin`)。無ければ空。""" + from supervise_lib.decl import read_decl + + return str((read_decl([Path(main)], "supervise.json").get("release") or {}).get("plugin") or "") + + +def git_head(main: Path) -> str: + """ベースブランチの先頭のコミット。ベースブランチが決まらなければ空。""" + ref = base_tracking_ref(main) + if not ref: + return "" subprocess.run(["git", "fetch", "-q", "origin"], cwd=main, capture_output=True) got = subprocess.run(["git", "rev-parse", ref], cwd=main, capture_output=True, text=True) return (got.stdout or "").strip() def dev_version(main: Path) -> str: - """`origin/develop` の `plugin.json` の開発版の版数から接尾辞を外した版(出す版)。""" + """ベースブランチの配るプラグインの `plugin.json` の開発版の版数から接尾辞を外した版(出す版)。決まらなければ空。""" import re - for rel in ("plugins/ndf/.claude-plugin/plugin.json",): - got = subprocess.run(["git", "show", f"origin/develop:{rel}"], cwd=main, capture_output=True, text=True) - if got.returncode == 0: - try: - return re.sub(r"-.*$", "", json.loads(got.stdout).get("version") or "") - except ValueError: - pass - return "" + from release_version import plugin_json_rel + + ref, plugin = base_tracking_ref(main), release_plugin(main) + rel = plugin_json_rel(main, ref, plugin) if ref and plugin else None + if not rel: + return "" + got = subprocess.run(["git", "show", f"{ref}:{rel}"], cwd=main, capture_output=True, text=True) + try: + return re.sub(r"-.*$", "", json.loads(got.stdout).get("version") or "") if got.returncode == 0 else "" + except ValueError: + return "" # ---- 耐久ステップ ---- @@ -117,6 +139,8 @@ def step_build(main: str, name: str, seq: int, k: int) -> dict: if seq > pr.MAX_HELPER_SEQ: return {"error": f"配布の連番 {seq} が一時レーンの名前の上限 {pr.MAX_HELPER_SEQ} を超える"} version = dev_version(Path(main)) + if not version: + return {"error": "出す版を決められない(ベースブランチと .ndf/supervise.json の release.plugin の plugin.json の版を読めない)"} d = pr.build_plans(Path(main), name, seq, k, version) return {"dir": str(d), "version": version} diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index 7d8c3d403..e4637507c 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -8,11 +8,11 @@ | `start <計画名>` | `run` をセッションから切り離して起動する(動いていれば今の pid を返す) | | `run <計画名>` | 耐久ワークフローを始めるか続け、終わるまで動き続ける。ラッパーの外・tmux が無ければ手で起動する 1 行を返す(3) | | `wait <計画名> [--after ] [--timeout 秒]` | 状態の変化(notice / release / confirmed / questions)か終わりまで待つ。上限なら 3 | -| `present <計画名> <連番> <提示> --approval <承認資料> --out <パス> [--first ]` | 範囲の Pull Request をレーンへ対応づける | +| `present <計画名> <連番> <提示> --approval <承認資料>` | 範囲の Pull Request をレーンへ対応づけ、提示の置き場(`releases/<連番>/<提示>/`)へ書く | | `confirm <計画名> <連番> [--take new\\|first]` | 承認ゲート 2 の承認を送る | | `settle <計画名> <連番> (--done \\| --rejected [--reason 文])` | 本番の配布の結果を送る | | `stop <計画名>` | 計画の全レーンを止め、耐久ワークフローを終える | -| `request <計画名> --lane <名> --state <パス> --out <パス> [--prs ...] [--issues ...]` | 配布の依頼を送る(10)か、すでに `main` にあれば送らない(0) | +| `request <計画名> --lane <名> --state <パス> [--prs ...] [--issues ...]` | 配布の依頼を送る(10)か、すでに `main` にあれば送らない(0) | | `await <計画名> --from <パス> [--timeout 秒]` | 依頼の答えを読む(released 0・rejected 10・failed 1・まだ無い 3) | 置き場はメインディレクトリの `.ndf/parallel/<計画名>/`(コミットしない)。正本の手順は @@ -78,8 +78,12 @@ def cmd_init(a) -> None: plan, items, metrics = pp.check_data(raw) if plan is None: reply("stopped", f"並列の計画 {a.name} の検査に誤りがある({metrics['errors']} 件)。何も書いていない", items, 1, **metrics) + import handoff + try: written = pp.write_all(main, plan, raw) + except handoff.Failure as exc: + reply("stopped", f"引継ぎ文書の置き場が無い: {exc}", items, exc.code) except FileExistsError as exc: reply("stopped", f"在る文書を上書きしないため何も書いていない: {exc}", items, 1, existing=str(exc).split(", ")) except OSError as exc: @@ -164,9 +168,9 @@ def cmd_start(a) -> None: if pl.run_status(main, a.name) is None: _manual_or_none(main, plan) log = pp.plan_dir(main, a.name) / "run.log" - with open(log, "ab") as f: + with open(pp.plan_dir(main, a.name) / "run.log", "ab") as f: p = subprocess.Popen([sys.executable, str(SELF), "run", a.name], cwd=main, stdout=f, stderr=f, start_new_session=True) - pidfile.write_text(f"{p.pid}\n") + (pp.plan_dir(main, a.name) / "run.pid").write_text(f"{p.pid}\n") reply("ok", f"run を切り離して起動した(pid {p.pid})", pid=p.pid, log=str(log), next=_wait_line(a.name)) @@ -208,15 +212,15 @@ def cmd_present(a) -> None: if plan is None: reply("stopped", f"並列の計画 {a.name} の検査に誤りがある", items, 1) d = pr.release_dir(main, a.name, a.n, a.k) - first = json.loads(Path(a.first).read_text(encoding="utf-8")) if a.first and Path(a.first).is_file() else None + p1 = pr.release_dir(main, a.name, a.n, 1) / "presented.json" + first = json.loads(p1.read_text(encoding="utf-8")) if a.k != 1 and p1.is_file() else None reqs = _requests_for(main, a.name) try: - got = pr.present(main, plan, Path(a.approval), reqs, Path(a.out), d / "presented.json", first) + got = pr.present(main, plan, Path(a.approval), reqs, d, first) except ValueError as exc: reply("stopped", f"承認資料に承認したコミットが無い: {exc}", code=1) except OSError as exc: reply("stopped", f"読めない: {exc}", code=2) - Path(a.out).with_name("approved.txt").write_text(got["sha"] + "\n", encoding="utf-8") reply( "ok", f"提示 {a.n}-{a.k}: {got['sha'][:12]} まで(Pull Request {len(got['prs'])} 本)", @@ -299,10 +303,10 @@ def cmd_request(a) -> None: reply("stopped", f"並列の計画 {a.name} が動いていない", code=1) if a.prs and pl.merged_on_main(main, a.prs): version = pl.released_version(main, a.name, a.prs) - pl.write_out(Path(a.out), {"result": "released", "version": version, "prs": a.prs}) + pl.write_request(Path(a.state), {"result": "released", "version": version, "prs": a.prs}) reply("ok", "自分の Pull Request はすでに main にある。依頼を送らずに続きへ進む", result="released", version=version) rid = pl.send_release_request(main, a.name, a.lane, a.prs, a.issues, Path(a.state)) - pl.write_out(Path(a.out), {"id": rid, "plan": a.name, "lane": a.lane, "prs": a.prs}) + pl.write_request(Path(a.state), {"id": rid, "plan": a.name, "lane": a.lane, "prs": a.prs}) reply("gate", f"配布の依頼 {rid} を送った(配布待ち)。答えは parallel.py await で読む", code=10, id=rid, result="requested") @@ -335,8 +339,16 @@ def cmd_await(a) -> None: def build_parser() -> argparse.ArgumentParser: ap = argparse.ArgumentParser(prog="parallel.py", description=__doc__.splitlines()[0]) sub = ap.add_subparsers(dest="cmd", required=True) - for n in ("init", "check", "start", "run", "stop"): - sub.add_parser(n).add_argument("name") + ini = sub.add_parser("init") + ini.add_argument("name") + chk = sub.add_parser("check") + chk.add_argument("name") + sta = sub.add_parser("start") + sta.add_argument("name") + run_ = sub.add_parser("run") + run_.add_argument("name") + stp = sub.add_parser("stop") + stp.add_argument("name") w = sub.add_parser("wait") w.add_argument("name") w.add_argument("--after", type=int, default=0) @@ -346,8 +358,6 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("n", type=int) p.add_argument("k", type=int) p.add_argument("--approval", required=True) - p.add_argument("--out", required=True) - p.add_argument("--first") c = sub.add_parser("confirm") c.add_argument("name") c.add_argument("n", type=int) @@ -365,7 +375,6 @@ def build_parser() -> argparse.ArgumentParser: r.add_argument("--prs", type=int, nargs="*", default=[]) r.add_argument("--issues", type=int, nargs="*", default=[]) r.add_argument("--state", required=True) - r.add_argument("--out", required=True) aw = sub.add_parser("await") aw.add_argument("name") aw.add_argument("--from", required=True) diff --git a/plugins/ndf/scripts/tests/test_parallel_plan.py b/plugins/ndf/scripts/tests/test_parallel_plan.py index 8294e9360..627061d4c 100644 --- a/plugins/ndf/scripts/tests/test_parallel_plan.py +++ b/plugins/ndf/scripts/tests/test_parallel_plan.py @@ -29,7 +29,7 @@ "touches": ["plugins/ndf/"], }, {"name": "lflow", "input": {"kind": "loop", "loop": "lflow", "args": ["--milestones", "23"]}, "plans": 2, "touches": ["docs/"]}, - {"name": "lfac", "input": {"kind": "sprint", "issues": [2400]}, "plans": 2, "touches": ["factory/"], "after": ["lflow"]}, + {"name": "lfac", "input": {"kind": "sprint", "issues": [2400]}, "plans": 2, "touches": ["web/"], "after": ["lflow"]}, ], } @@ -101,6 +101,7 @@ def test_ready_lanes_waits_for_dependency(): def _git_main(tmp_path: Path) -> Path: subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + (tmp_path / ".ndf").mkdir() # 引継ぎ文書の置き場(development-workflow の手順 0 が作る) return tmp_path @@ -139,7 +140,7 @@ def test_init_with_error_writes_nothing(tmp_path): main = _git_main(tmp_path) code, res = _cli(main, "init", "p1", stdin=json.dumps(variant(x=lambda d: d["lanes"][1].__setitem__("after", ["lfac"])))) assert code == 1 and any(i["rule"] == "I3" for i in res["items"]) - assert not (main / ".ndf").exists() + assert not (main / ".ndf" / "parallel").exists() and not (main / ".ndf" / "handoff").exists() def test_run_outside_wrapper_returns_manual_lines(tmp_path, monkeypatch): @@ -194,3 +195,11 @@ def test_answerable_and_moved(): pending = [{"id": "a", "prs": [1]}, {"id": "b", "prs": [5]}] assert prl.answerable(pending, [1, 2]) == ["a"] assert prl.moved("abc", "abc") is False and prl.moved("abc", "def") is True + + +@pytest.mark.parametrize("name,ok", [("loop-lroot-lane", True), ("sprint-p3", True), ("lane-lroot", False), ("loop-lroot-history", False)]) +def test_handoff_name_follows_handoff_py(name, ok): + """レーンの引継ぎ文書の名は handoff.py の名の形だけを受ける(handoff.py の副命令で扱えない名を作らない)。""" + plan, _, metrics = pp.check_data(variant(x=lambda d: d["lanes"][0].__setitem__("handoff", name))) + assert (plan is not None) is ok + assert (metrics["errors"] == 0) is ok diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index 1d05d482a..1150f41d9 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import subprocess import sys import textwrap import time @@ -54,7 +55,8 @@ @pytest.fixture def env(tmp_path, monkeypatch): main = tmp_path / "repo" - main.mkdir() + (main / ".ndf").mkdir(parents=True) + subprocess.run(["git", "init", "-q", str(main)], check=True) plan = pp.check_data(PLAN)[0] pp.write_all(main, plan, PLAN) fake = tmp_path / "fake_relay.py" @@ -65,7 +67,7 @@ def env(tmp_path, monkeypatch): monkeypatch.setenv("FAKE_STATES", str(states)) monkeypatch.setenv("NDF_PARALLEL_POLL", "0.2") heads = {"v": "sha1"} - monkeypatch.setattr(prun, "git_head", lambda _m, ref="origin/develop": heads["v"]) + monkeypatch.setattr(prun, "git_head", lambda _m: heads["v"]) monkeypatch.setattr(prun, "dev_version", lambda _m: "1.2.3") def builder(_main, _name, d, _v): @@ -178,7 +180,7 @@ def test_send_request_is_idempotent(env, tmp_path): st = tmp_path / "st" a = pl.send_release_request(main, "p1", "la", [10], [1], st) b = pl.send_release_request(main, "p1", "la", [10], [1], st) - assert a == b and (st / "work" / "request-id").read_text().strip() == a + assert a == b and (st / "work" / "request-id.txt").read_text().strip() == a until(lambda: (event(main, "p1", "lane-la") or {}).get("received") == 1) time.sleep(0.6) assert event(main, "p1", "lane-la")["received"] == 1 diff --git a/plugins/ndf/skills/development-workflow/references/parallel.md b/plugins/ndf/skills/development-workflow/references/parallel.md index a612783a7..1c3f887ae 100644 --- a/plugins/ndf/skills/development-workflow/references/parallel.md +++ b/plugins/ndf/skills/development-workflow/references/parallel.md @@ -42,7 +42,7 @@ 並列の計画の下のレーン(`NDF_RELAY_NAME` が動いている計画のレーンにある)では、スプリントの本番のステージが 配布の依頼のプラン(ステップ `parallel-request` の 1 つ)になる。依頼を送ったら承認ゲートとして終わり、レーンは答えを待たずに -次の周回へ進む。答えは `parallel.py await p1 --from <依頼の記録> --timeout 0` で待たずに読む。自分の Pull Request が +次の周回へ進む。答えは `parallel.py await p1 --from <プランの状態ディレクトリ>/work/request.txt --timeout 0` で待たずに読む。自分の Pull Request が すでに `main` にあれば、依頼を送らずに配布済みとして続きへ進む。レーンで `supervise.py queue` の `--max` を省くと、 並列の計画のそのレーンのプランの本数で流す。 diff --git a/plugins/ndf/skills/issue-plan-strategy/references/parallel-plan.md b/plugins/ndf/skills/issue-plan-strategy/references/parallel-plan.md index 8f06d2f39..17a509ab5 100644 --- a/plugins/ndf/skills/issue-plan-strategy/references/parallel-plan.md +++ b/plugins/ndf/skills/issue-plan-strategy/references/parallel-plan.md @@ -14,7 +14,7 @@ python3 "$NDF_SCRIPTS/parallel.py" init p1 <<'EOF' "lanes": [ {"name": "lroot", "input": {"kind": "loop", "loop": "lroot", "args": ["--milestones", "26"]}, "plans": 2, "touches": ["plugins/ndf/"]}, {"name": "lflow", "input": {"kind": "loop", "loop": "lflow", "args": ["--milestones", "23"]}, "plans": 2, "touches": ["docs/"]}, - {"name": "lfac", "input": {"kind": "sprint", "issues": [2400]}, "plans": 2, "touches": ["factory/"], "after": ["lflow"]} + {"name": "lfac", "input": {"kind": "sprint", "issues": [2400]}, "plans": 2, "touches": ["web/"], "after": ["lflow"]} ]} EOF ``` @@ -32,6 +32,7 @@ EOF | `plans` | 同時に流すプランの本数(1 以上) | | `touches` | 触る場所(パスか glob) | | `after` | 始める条件。ここに並べたレーンがすべて止まってから起動する | +| `handoff` | 任意。引継ぎ文書の名(既定はループなら `loop-<ループ名>`、スプリントなら `issue-<最初の番号>`)。名の形は `handoff.py` と同じ `loop-`・`sprint-`・`milestone-`・`issue-` で始まるものだけで、在る文書と重なるときに別の名を与える(例: `loop-lroot2-lane`) | 計画名は 24 字まで。レーンに共通の決まり(プランの本数の配分・触る場所・配布の担当)は `plan.md` の 1 箇所に置き、 引継ぎ文書へは写さない。引継ぎ文書は計画名・レーン名・`plan.md` のパスと、レーンの開始のコマンドだけを持つ。 From e3a3be678ed0c421d8914b83b41297ba0d78c65d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 04:59:26 +0000 Subject: [PATCH 11/70] =?UTF-8?q?Docs:=20=E5=A4=9A=E9=87=8D=E8=B5=B7?= =?UTF-8?q?=E5=8B=95=E3=81=AE=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=AB?= =?UTF-8?q?=E5=BD=B9=E5=89=B2=E3=81=AE=E6=95=B0=E3=81=88=E6=96=B9=E3=81=A8?= =?UTF-8?q?=20role=20=E3=81=AE=E5=89=AF=E5=91=BD=E4=BB=A4=E3=82=92?= =?UTF-8?q?=E3=80=81=E6=9C=AC=E7=95=AA=E3=81=AE=E8=A8=98=E9=8C=B2=E3=81=AE?= =?UTF-8?q?=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=AB=E3=83=AC=E3=83=BC?= =?UTF-8?q?=E3=83=B3=E3=81=AE=E9=85=8D=E5=B8=83=E3=81=AE=E4=BE=9D=E9=A0=BC?= =?UTF-8?q?=E3=81=AE=E3=83=97=E3=83=A9=E3=83=B3=E3=82=92=E6=9B=B8=E3=81=8F?= =?UTF-8?q?=EF=BC=88#2436=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../ndf-production-release-record.md | 12 ++++++++++++ .../ndf-relay-multi-session-supervisor.md | 15 +++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/docs/specifications/ndf-production-release-record.md b/docs/specifications/ndf-production-release-record.md index a3cf695d8..9207b5bec 100644 --- a/docs/specifications/ndf-production-release-record.md +++ b/docs/specifications/ndf-production-release-record.md @@ -10,6 +10,8 @@ - **`pace: normal` のスプリントでも、`new sprint` が承認ゲート 2 と本番のステージ(計画名 `release-prod`)まで目録へ書き出す。** 承認ゲート 2 の後に conductor が組み立てるコマンドは無く、目録の「本番」のステージの `command`(`--done` を含む。#1426)を打つだけで 本番の配布が流れる(#1914。「`pace: normal` のスプリントの本番のステージ」) +- **並列の計画のレーンでは、本番のステージを配布の依頼のプランに置き換える。** レーンは承認ゲート 2 を置かず、本番の配布を + オーガナイザーへ頼む(#2436。下の「並列の計画のレーンの本番のステージ」) 例: 版 `10.17.30`(本番のリリースの PR は #1268。記録が無く、課題を手で閉じた版である)を今のプランで出したとき。 直前の正式版は `10.17.29`。スプリントの PR は実際には 13 本あるが、説明のため #1260 と #1262 の 2 本に絞る。 @@ -238,6 +240,16 @@ stateDiagram-v2 `--version` を省いたときは `version` が空で、worktree・ブランチ・承認資料のパスは版の差し込み(`{release_version}`・`{release_base}`)で書き、 プランの「版」の欄の `系列` に開発版のプランの名前 `release` を入れる。キューが本番のプランを流す直前に、開発版のプランの版の記録(`version.json`)の `base` で埋める(#2164)。 + +### 並列の計画のレーンの本番のステージ(#2436) + +プランを組む時点に、ラッパーの名前(`NDF_RELAY_NAME`)が動いている並列の計画のレーンに当たれば(`supervise_lib/sprint.py` の `parallel_lane`)、 +本番のステージを組む 4 か所(`fast_sprint_plans`・`auto_sprint_plans`・`close_waves`・`sprint_plans`)はどれも `prod_plans` を通り、 +計画名 `release-prod` の本番のリリースプランの代わりに計画名 `release-request` の配布の依頼のプラン(`parallel_release.plan_release_request`)を置く。 +`pace: normal` の `sprint_plans` は、レーンのとき `関門 2` のステージも置かない。承認ゲート 2 はオーガナイザーがまとめた配布で出す。 +配布の依頼のプランはステップ `parallel-request`(`parallel.py request`)の 1 つだけを持ち、依頼を送れば承認ゲート(終了コード 10)で終わり、 +自分の Pull Request がすでに `main` にあれば送らずに 0 で終わる。手順は `development-workflow` の +[references/parallel.md](../../plugins/ndf/skills/development-workflow/references/parallel.md) にある。 雛形(`release_templates.plan_release`)が組んだステップを目録の側で書き換えず、`new release --version <正式版> --prs {queue_pr:check} --channel prod` と同じ出力に `課題` が入る(スプリントの工程は雛形に対して順応者の関係にある)。 diff --git a/docs/specifications/ndf-relay-multi-session-supervisor.md b/docs/specifications/ndf-relay-multi-session-supervisor.md index 9b29989e1..c2c4db74b 100644 --- a/docs/specifications/ndf-relay-multi-session-supervisor.md +++ b/docs/specifications/ndf-relay-multi-session-supervisor.md @@ -59,6 +59,7 @@ tmux が PATH に無い環境では、2 本目は `ndf-relay: tmux が PATH に | 依頼はファイルの受付箱で渡し、登録簿を書くのは統括役だけにする。Unix ドメインソケットは使わない | 名前の重複と上限の判定が入口ごとに分かれない。統括役が居ない間に置かれた依頼が残り、次の統括役が処理できる | | 同時に動かす数の上限は環境変数 `NDF_RELAY_MAX_NAMED`(既定 3)で受け、`.ndf/` に置かない | 上限が守るのは同じコンテナ・同じアカウントの資源で、リポジトリごとの値ではない。`NDF_RELAY_MAX_STARTS` と同じく統括役の環境から 1 つに決まる | | 上限は統括役・名前つき・名前の無い今のラッパーを合わせて数え、起動した直後でロックの前のものも数える | 1 回の応答に上限を超える数の起動のブロックを置いても、先に起動したものがロックを取る前に次を通さない | +| 並列の計画のオーガナイザーの印(役割 `organizer`)を持つ動いているラッパーは 1 本だけ数から外し、一時レーン(役割 `helper`)は数から外して同時に 1 本までに限る(#2436) | オーガナイザーが上限の 1 本を使うと、既定の 3 でレーンを 3 本起動できない。外す経路を 1 本ずつに限り、印を置けば上限を越えられる道を増やさない | | 起動のブロックは情報文字列 `ndf-spawn <名前>` の囲みにし、`ndf-next` と同じ包み(`lib/md.py`)で拾う | 拾う規則が 1 つに残り、conductor の書き方も分かれない。区間を終えないので `next.json` を書かず、Stop hook の 5 秒の上限の中で依頼を置くだけにする | | 承認待ち通知は各ラッパーの待ち通知が名前を付けて出し、統括役は見回さない | 統括役が落ちて次の `claude` が来るまでの間も通知が出る。統括役が見回すと今の通知と 2 重になる。端末の結び付きは見ずに常に付ける(hook の中で tmux を呼ばない) | | 一覧の状態は作業ディレクトリの今のファイルから導き、語は Factory の `awaiting_approval` に揃える | ラッパーの状態機械に書き込みを足さずに済み、kill された後も正しく止まったと出る。Factory の実行の状態と同じ名前になる | @@ -78,7 +79,7 @@ tmux が PATH に無い環境では、2 本目は `ndf-relay: tmux が PATH に - **同じコンテナで統括役は高々 1 つである。** `hub.lock` の排他を持つプロセスだけが統括役で、取れなかった `run` は依頼する側になる - **動いている名前つきのラッパーの名前は重ならない。** tmux のセッションが在る名前も取られているものとして扱う -- **動いているラッパーの数は上限以下である。** 数えるのは「`relay.lock` が取られている作業ディレクトリ(名前の無いものを含む)」と「登録簿の行のうち、tmux のセッションが在るのに同じ名前の作業ディレクトリがまだ `relay.lock` を取っていないもの」の和で、重複は名前で除く(`hub.py` の `live`) +- **動いているラッパーの数は上限以下である。** 数えるのは「`relay.lock` が取られている作業ディレクトリ(名前の無いものを含む)」と「登録簿の行のうち、tmux のセッションが在るのに同じ名前の作業ディレクトリがまだ `relay.lock` を取っていないもの」の和で、重複は名前で除く(`hub.py` の `live`)。この和から、役割 `organizer` のものを 1 本だけと、役割 `helper` のものすべてを外す。役割 `helper` の動いているラッパーは 1 本以下である(#2436) - **登録簿(`registry.json`)を書くのは統括役だけである。** 頼む側(`hub_client.py`)は受付箱へ依頼を置くだけにする - **統括役は名前つきのラッパーの区間の切り替え・`ndf-next` の拾い方・アカウントの選び方に触れない。** tmux の中で動くのは今の `Relay` である - **統括役が終わっても名前つきのラッパーは止まらない。** window の親は tmux のサーバで、統括役の終了は届かない @@ -213,8 +214,9 @@ stateDiagram-v2 | 副命令 | 引数 | 終了コード | 出力 | | --- | --- | --- | --- | | `run`(2 本目) | `[claude の引数 ...]`。名前は `NDF_RELAY_NAME` | 統括役が起動したら `attach-session` へ exec し、その終了コード。断られたら結果の `metrics.exit_code`(`bad-name`・`name-taken`・`limit`・`no-tmux` は 3、`tmux-failed` は 1)。tmux が無い・統括役が答えないときは前景の起動になり、1 本のラッパーと同じ | 断り: `ndf-relay: 起動しない()`。tmux が無い: `ndf-relay: tmux が PATH に無いため統括しない。…`。答えない: `ndf-relay: 統括役が <秒> 秒答えないため、…` | -| `spawn` | `--name <名前> [--json] -- <最初の入力>` | 0: 起動した / 2: `--name` が無い(使い方を出す)/ 3: 名前の形が違う(統括役に頼まずに断る)・名前が重なる・上限・統括役の側で tmux が無い / 1: 統括役が居ない・答えない(`no-hub`)・tmux のセッションを作れない | 結果(`tool: relay-spawn`)。成功の `summary` は `<名前> を起動した(relay.py attach <名前> で結び付ける)` | +| `spawn` | `--name <名前> [--role helper --plan <計画名>] [--json] -- <最初の入力>` | 0: 起動した / 2: `--name` が無い・`--role` が `helper` でない・`--role` に `--plan` が無い(使い方を出す)/ 3: 名前の形が違う(統括役に頼まずに断る)・名前が重なる・上限(`--role helper` は上限を見ず、動いている `helper` があれば断る)・統括役の側で tmux が無い / 1: 統括役が居ない・答えない(`no-hub`)・tmux のセッションを作れない | 結果(`tool: relay-spawn`)。成功の `summary` は `<名前> を起動した(relay.py attach <名前> で結び付ける)` | | `ls` | `[--json]` | 0 / 2: 状態ディレクトリを読めない | `--json` は 1 行の JSON(`tool: relay-ls`・`status: ok`・`summary`・`items`・`metrics`)。無ければ見出しつきの表(列: 名前・状態・承認待ち・区間・費用・時刻・アカウント・結び付き・統括役。空は `-`、真偽は `yes` / `no`) | +| `role` | `organizer\|helper --plan <計画名>` / `--clear` | 0: 作業ディレクトリ(`NDF_RELAY_DIR`)へ役割のファイル `role.json`(`role`・`plan`・`at`)を書いた・消した / 2: 役割か `--plan` が無い / 1: ラッパーの外(`NDF_RELAY_DIR` が無い) | 1 行の JSON(`tool: relay-role`)(#2436) | | `attach` | `<名前>` | 名前の tmux のセッションへ `attach-session` で exec する / 3: セッションが無い / 2: 名前が 1 つでない・標準入力か標準出力が端末でない | 失敗の理由を `relay.py:` の 1 行で標準エラーへ | | `stop` | `[<名前>] [--json]` | 名前なし: 1 本のラッパーの仕様のまま。名前あり: 0: 止めた / 3: 動いているものも tmux のセッションも無い(`not-found`) | 名前あり: 結果(`tool: relay-stop`) | | `restart` | `<名前> [--json]` | 0: 起動し直した / 2: 名前が 1 つでない / 3: 登録簿の行も動いているものも無い(`not-found`)・統括役自身(`hub_self`。止めない)・起動の判定で断られた / 1: 統括役が居ない・答えない・tmux のセッションを作れない | 結果(`tool: relay-restart`) | @@ -245,8 +247,9 @@ stateDiagram-v2 | `ndf_version` | str か null | 最後の start の行の `plugin_version` | | `attached` | bool か null | 端末が結び付いているか。tmux のセッションが無ければ null | | `hub` | bool | 統括役を兼ねるラッパー(動いていて、作業ディレクトリが `hub.json` の `dir`)か | +| `role` | str か null | 役割(`organizer` / `helper`)。作業ディレクトリの `role.json`、無ければ登録簿の行の `role`(#2436) | -`metrics` は `running`・`awaiting_approval`・`stopped` の数と `limit`(`NDF_RELAY_MAX_NAMED` の値)を持つ。 +`metrics` は `running`・`awaiting_approval`・`stopped` の数と `limit`(`NDF_RELAY_MAX_NAMED` の値)、`hub`(統括役が動いているか)・`tmux`(tmux が PATH にあるか)を持つ。 ## データ・設定 @@ -258,8 +261,8 @@ stateDiagram-v2 | --- | --- | --- | --- | | `hub.lock` | 統括役の排他 | 統括役(`claim`) | —(OS がプロセスの終了で放す) | | `hub.json` | `pid`・`dir`(統括役のラッパーの作業ディレクトリ)・`claimed_at` | 統括役(`start`) | — | -| `registry.json` | `version`(1)と `entries.<名前>`(`session`・`cwd`・`args`・`created_at`・`by`) | 統括役だけ | —(名前ごとの行を上書きする。止まった行は同じ名前で起動し直すまで残る) | -| `requests/.json` | `kind`(`spawn` / `restart`)・`name`(null は自動で付ける)・`cwd`・`args`・`by`・`at`。環境変数は載せない | 頼む側(0600) | 統括役が `.taken` へ `rename` して受け取った時点。待ちを切った頼む側 | +| `registry.json` | `version`(1)と `entries.<名前>`(`session`・`cwd`・`args`・`created_at`・`by`。`spawn --role helper` で起動した行は `role`・`plan` も持つ) | 統括役だけ | —(名前ごとの行を上書きする。止まった行は同じ名前で起動し直すまで残る) | +| `requests/.json` | `kind`(`spawn` / `restart`)・`name`(null は自動で付ける)・`cwd`・`args`・`by`・`at`、`spawn --role helper` なら `role`・`plan`。環境変数は載せない | 頼む側(0600) | 統括役が `.taken` へ `rename` して受け取った時点。待ちを切った頼む側 | | `results/.json` | 結果(上の「結果の形」) | 統括役 | 頼む側が読んで消す。読まれずに 10 分を過ぎたものは次の統括役が消す | | `hub-log.jsonl` | `at`・`event`(`claimed` / `started` / `restarted` / `refused` / `stopped` / `failed`)・`name`・`reason` を 1 行ずつ | 統括役・名前つきの `stop`(`lib/locks.py` の `append_locked`) | — | @@ -354,5 +357,5 @@ tmux のソケットの置き場は tmux の規則(`TMUX_TMPDIR`)のまま - [ndf-relay-segment-restart.md](ndf-relay-segment-restart.md) — 1 本のラッパーの入出力と区間の切り替え - [ndf-relay-install-and-restart.md](ndf-relay-install-and-restart.md) — 導入・`/ndf:restart` - [#1587](https://github.com/devbasex/ai-plugins/issues/1587) — ラッパーの入れ替え(入れ替えた後に統括役を取り直す) -- [#2189](https://github.com/devbasex/ai-plugins/issues/2189) — Factory の手元のランナー。起動・一覧・停止の名前と形を揃える相手 +- [#2189](https://github.com/devbasex/ai-plugins/issues/2189) — Factory の手元のマシンで動くランナー。起動・一覧・停止の名前と形を揃える相手 - [ndf-factory-runs-and-runner.md](ndf-factory-runs-and-runner.md) — Factory の実行ジョブと実行の枠 From 08cf34421f675a313aacbade7f86976b29c8a0ef Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 05:13:12 +0000 Subject: [PATCH 12/70] =?UTF-8?q?docs:=20=E4=BB=95=E6=A7=98=E3=81=A8?= =?UTF-8?q?=E6=89=8B=E9=A0=86=E3=81=AE=E6=96=87=E6=9B=B8=E3=81=8B=E3=82=89?= =?UTF-8?q?=E8=AA=B2=E9=A1=8C=E7=95=AA=E5=8F=B7=E3=81=AE=E7=94=B1=E6=9D=A5?= =?UTF-8?q?=E3=81=A8=E6=AF=94=E8=BC=83=E3=81=AE=E8=AA=9E=E3=82=92=E5=A4=96?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/specifications/ndf-production-release-record.md | 2 +- docs/specifications/ndf-relay-multi-session-supervisor.md | 8 ++++---- .../skills/development-workflow/references/parallel.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/specifications/ndf-production-release-record.md b/docs/specifications/ndf-production-release-record.md index 9207b5bec..1fc3d93be 100644 --- a/docs/specifications/ndf-production-release-record.md +++ b/docs/specifications/ndf-production-release-record.md @@ -241,7 +241,7 @@ stateDiagram-v2 プランの「版」の欄の `系列` に開発版のプランの名前 `release` を入れる。キューが本番のプランを流す直前に、開発版のプランの版の記録(`version.json`)の `base` で埋める(#2164)。 -### 並列の計画のレーンの本番のステージ(#2436) +### 並列の計画のレーンの本番のステージ プランを組む時点に、ラッパーの名前(`NDF_RELAY_NAME`)が動いている並列の計画のレーンに当たれば(`supervise_lib/sprint.py` の `parallel_lane`)、 本番のステージを組む 4 か所(`fast_sprint_plans`・`auto_sprint_plans`・`close_waves`・`sprint_plans`)はどれも `prod_plans` を通り、 diff --git a/docs/specifications/ndf-relay-multi-session-supervisor.md b/docs/specifications/ndf-relay-multi-session-supervisor.md index c2c4db74b..0e79155a7 100644 --- a/docs/specifications/ndf-relay-multi-session-supervisor.md +++ b/docs/specifications/ndf-relay-multi-session-supervisor.md @@ -59,7 +59,7 @@ tmux が PATH に無い環境では、2 本目は `ndf-relay: tmux が PATH に | 依頼はファイルの受付箱で渡し、登録簿を書くのは統括役だけにする。Unix ドメインソケットは使わない | 名前の重複と上限の判定が入口ごとに分かれない。統括役が居ない間に置かれた依頼が残り、次の統括役が処理できる | | 同時に動かす数の上限は環境変数 `NDF_RELAY_MAX_NAMED`(既定 3)で受け、`.ndf/` に置かない | 上限が守るのは同じコンテナ・同じアカウントの資源で、リポジトリごとの値ではない。`NDF_RELAY_MAX_STARTS` と同じく統括役の環境から 1 つに決まる | | 上限は統括役・名前つき・名前の無い今のラッパーを合わせて数え、起動した直後でロックの前のものも数える | 1 回の応答に上限を超える数の起動のブロックを置いても、先に起動したものがロックを取る前に次を通さない | -| 並列の計画のオーガナイザーの印(役割 `organizer`)を持つ動いているラッパーは 1 本だけ数から外し、一時レーン(役割 `helper`)は数から外して同時に 1 本までに限る(#2436) | オーガナイザーが上限の 1 本を使うと、既定の 3 でレーンを 3 本起動できない。外す経路を 1 本ずつに限り、印を置けば上限を越えられる道を増やさない | +| 並列の計画のオーガナイザーの印(役割 `organizer`)を持つ動いているラッパーは 1 本だけ数から外し、一時レーン(役割 `helper`)は数から外して同時に 1 本までに限る | オーガナイザーが上限の 1 本を使うと、既定の 3 でレーンを 3 本起動できない。外す経路を 1 本ずつに限り、印を置けば上限を越えられる道を増やさない | | 起動のブロックは情報文字列 `ndf-spawn <名前>` の囲みにし、`ndf-next` と同じ包み(`lib/md.py`)で拾う | 拾う規則が 1 つに残り、conductor の書き方も分かれない。区間を終えないので `next.json` を書かず、Stop hook の 5 秒の上限の中で依頼を置くだけにする | | 承認待ち通知は各ラッパーの待ち通知が名前を付けて出し、統括役は見回さない | 統括役が落ちて次の `claude` が来るまでの間も通知が出る。統括役が見回すと今の通知と 2 重になる。端末の結び付きは見ずに常に付ける(hook の中で tmux を呼ばない) | | 一覧の状態は作業ディレクトリの今のファイルから導き、語は Factory の `awaiting_approval` に揃える | ラッパーの状態機械に書き込みを足さずに済み、kill された後も正しく止まったと出る。Factory の実行の状態と同じ名前になる | @@ -79,7 +79,7 @@ tmux が PATH に無い環境では、2 本目は `ndf-relay: tmux が PATH に - **同じコンテナで統括役は高々 1 つである。** `hub.lock` の排他を持つプロセスだけが統括役で、取れなかった `run` は依頼する側になる - **動いている名前つきのラッパーの名前は重ならない。** tmux のセッションが在る名前も取られているものとして扱う -- **動いているラッパーの数は上限以下である。** 数えるのは「`relay.lock` が取られている作業ディレクトリ(名前の無いものを含む)」と「登録簿の行のうち、tmux のセッションが在るのに同じ名前の作業ディレクトリがまだ `relay.lock` を取っていないもの」の和で、重複は名前で除く(`hub.py` の `live`)。この和から、役割 `organizer` のものを 1 本だけと、役割 `helper` のものすべてを外す。役割 `helper` の動いているラッパーは 1 本以下である(#2436) +- **動いているラッパーの数は上限以下である。** 数えるのは「`relay.lock` が取られている作業ディレクトリ(名前の無いものを含む)」と「登録簿の行のうち、tmux のセッションが在るのに同じ名前の作業ディレクトリがまだ `relay.lock` を取っていないもの」の和で、重複は名前で除く(`hub.py` の `live`)。この和から、役割 `organizer` のものを 1 本だけと、役割 `helper` のものすべてを外す。役割 `helper` の動いているラッパーは 1 本以下である - **登録簿(`registry.json`)を書くのは統括役だけである。** 頼む側(`hub_client.py`)は受付箱へ依頼を置くだけにする - **統括役は名前つきのラッパーの区間の切り替え・`ndf-next` の拾い方・アカウントの選び方に触れない。** tmux の中で動くのは今の `Relay` である - **統括役が終わっても名前つきのラッパーは止まらない。** window の親は tmux のサーバで、統括役の終了は届かない @@ -216,7 +216,7 @@ stateDiagram-v2 | `run`(2 本目) | `[claude の引数 ...]`。名前は `NDF_RELAY_NAME` | 統括役が起動したら `attach-session` へ exec し、その終了コード。断られたら結果の `metrics.exit_code`(`bad-name`・`name-taken`・`limit`・`no-tmux` は 3、`tmux-failed` は 1)。tmux が無い・統括役が答えないときは前景の起動になり、1 本のラッパーと同じ | 断り: `ndf-relay: 起動しない()`。tmux が無い: `ndf-relay: tmux が PATH に無いため統括しない。…`。答えない: `ndf-relay: 統括役が <秒> 秒答えないため、…` | | `spawn` | `--name <名前> [--role helper --plan <計画名>] [--json] -- <最初の入力>` | 0: 起動した / 2: `--name` が無い・`--role` が `helper` でない・`--role` に `--plan` が無い(使い方を出す)/ 3: 名前の形が違う(統括役に頼まずに断る)・名前が重なる・上限(`--role helper` は上限を見ず、動いている `helper` があれば断る)・統括役の側で tmux が無い / 1: 統括役が居ない・答えない(`no-hub`)・tmux のセッションを作れない | 結果(`tool: relay-spawn`)。成功の `summary` は `<名前> を起動した(relay.py attach <名前> で結び付ける)` | | `ls` | `[--json]` | 0 / 2: 状態ディレクトリを読めない | `--json` は 1 行の JSON(`tool: relay-ls`・`status: ok`・`summary`・`items`・`metrics`)。無ければ見出しつきの表(列: 名前・状態・承認待ち・区間・費用・時刻・アカウント・結び付き・統括役。空は `-`、真偽は `yes` / `no`) | -| `role` | `organizer\|helper --plan <計画名>` / `--clear` | 0: 作業ディレクトリ(`NDF_RELAY_DIR`)へ役割のファイル `role.json`(`role`・`plan`・`at`)を書いた・消した / 2: 役割か `--plan` が無い / 1: ラッパーの外(`NDF_RELAY_DIR` が無い) | 1 行の JSON(`tool: relay-role`)(#2436) | +| `role` | `organizer\|helper --plan <計画名>` / `--clear` | 0: 作業ディレクトリ(`NDF_RELAY_DIR`)へ役割のファイル `role.json`(`role`・`plan`・`at`)を書いた・消した / 2: 役割か `--plan` が無い / 1: ラッパーの外(`NDF_RELAY_DIR` が無い) | 1 行の JSON(`tool: relay-role`) | | `attach` | `<名前>` | 名前の tmux のセッションへ `attach-session` で exec する / 3: セッションが無い / 2: 名前が 1 つでない・標準入力か標準出力が端末でない | 失敗の理由を `relay.py:` の 1 行で標準エラーへ | | `stop` | `[<名前>] [--json]` | 名前なし: 1 本のラッパーの仕様のまま。名前あり: 0: 止めた / 3: 動いているものも tmux のセッションも無い(`not-found`) | 名前あり: 結果(`tool: relay-stop`) | | `restart` | `<名前> [--json]` | 0: 起動し直した / 2: 名前が 1 つでない / 3: 登録簿の行も動いているものも無い(`not-found`)・統括役自身(`hub_self`。止めない)・起動の判定で断られた / 1: 統括役が居ない・答えない・tmux のセッションを作れない | 結果(`tool: relay-restart`) | @@ -247,7 +247,7 @@ stateDiagram-v2 | `ndf_version` | str か null | 最後の start の行の `plugin_version` | | `attached` | bool か null | 端末が結び付いているか。tmux のセッションが無ければ null | | `hub` | bool | 統括役を兼ねるラッパー(動いていて、作業ディレクトリが `hub.json` の `dir`)か | -| `role` | str か null | 役割(`organizer` / `helper`)。作業ディレクトリの `role.json`、無ければ登録簿の行の `role`(#2436) | +| `role` | str か null | 役割(`organizer` / `helper`)。作業ディレクトリの `role.json`、無ければ登録簿の行の `role` | `metrics` は `running`・`awaiting_approval`・`stopped` の数と `limit`(`NDF_RELAY_MAX_NAMED` の値)、`hub`(統括役が動いているか)・`tmux`(tmux が PATH にあるか)を持つ。 diff --git a/plugins/ndf/skills/development-workflow/references/parallel.md b/plugins/ndf/skills/development-workflow/references/parallel.md index 1c3f887ae..8716c7598 100644 --- a/plugins/ndf/skills/development-workflow/references/parallel.md +++ b/plugins/ndf/skills/development-workflow/references/parallel.md @@ -33,7 +33,7 @@ ## まとめた配布 -配布の依頼が届いた時点で流れている配布が無ければ、他のレーンを待たずに直ちに配布を組む。中身は依頼の集合ではなく、 +配布の依頼が届いた時点で流れている配布が無ければ、他のレーンを待たずに直ちに配布を組む。配布の中身は、 承認資料を作る時点の `origin/develop` のコミットまでの範囲で決まる。範囲に入ったレーンは、依頼していなくても結果を受ける。 承認の答えの時点で `develop` が進んでいれば、加わった範囲を添えて同じ承認ゲート 2 を 1 回だけ出し直す。 流れている配布は 1 本までで、範囲に入らない依頼は今の配布が終わった直後の次の配布になる。 From 2d7842df3c1e02fa760a7ab3cf1f02990a1b117e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 05:43:23 +0000 Subject: [PATCH 13/70] =?UTF-8?q?Test:=20=E7=8F=BE=E7=8A=B6=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=B6=B3=E3=81=99?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/scripts/lib/parallel=5Frun.py#parall?= =?UTF-8?q?el=5Fworkflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 計画の検査落ち・ls から消えたレーン・承認待ちの知らせ・配布の組み立ての失敗・却下・範囲外の依頼の持ち越しの 6 経路を固定する。 Item-Id: I-001 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .../ndf/scripts/tests/test_parallel_run.py | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index 1150f41d9..0a71a5ef9 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -184,3 +184,103 @@ def test_send_request_is_idempotent(env, tmp_path): until(lambda: (event(main, "p1", "lane-la") or {}).get("received") == 1) time.sleep(0.6) assert event(main, "p1", "lane-la")["received"] == 1 + + +# ---- 現状固定(parallel_workflow の分岐。リファクタリングの前に今の振る舞いを記録する) ---- + + +def resume(main, msg, key): + durable.send_to(pl.KIND, pl.record_identity(main, "p1"), pl.workflow_id("p1"), msg, "resume", key) + + +def stop(main): + wid = pl.workflow_id("p1") + durable.send_to(pl.KIND, pl.record_identity(main, "p1"), wid, {"kind": "stop"}, "inbox", f"{wid}:inbox:stop") + return durable.output_of(wid) + + +def test_char_invalid_plan_stops_without_spawning(env): + main, log = env["main"], env["log"] + (pp.plan_dir(main, "p1") / "plan.json").write_text(json.dumps({"version": 1})) + start(main) + out = durable.output_of(pl.workflow_id("p1")) + assert out["status"] == "stopped" and out["lanes"] == {} and out["releases"] == [] and out["pending"] == [] + assert isinstance(out["errors"], list) and out["errors"] + assert spawned(log) == [] and calls(log, "role") == [] + + +def test_char_lane_missing_from_ls_counts_as_stopped_and_finishes(env): + main, log = env["main"], env["log"] + start(main) # ls が何も返さないと、起動済みのレーンは止まったものとして扱う + out = durable.output_of(pl.workflow_id("p1")) + assert out["status"] == "finished" and spawned(log) == ["la", "lb"] + assert {n: st["state"] for n, st in out["lanes"].items()} == {"la": "stopped", "lb": "stopped"} + assert out["releases"] == [] and out["pending"] == [] + assert calls(log, "role")[0] == ["role", "organizer", "--plan", "p1"] and ["role", "--clear"] in calls(log, "role") + + +def test_char_awaiting_approval_pauses_with_notice(env): + main, states = env["main"], env["states"] + states.write_text(json.dumps({"la": "running"})) + start(main) + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "running") + states.write_text(json.dumps({"la": "awaiting_approval"})) + ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "notice" and e) + assert ev["lanes"] == ["la"] and ev["attach"] == [f"python3 {prun.RELAY} attach la"] + out = stop(main) + assert out["status"] == "stopped" and out["not_stopped"] == [] and out["lanes"]["la"]["state"] == "awaiting_approval" + + +def test_char_build_error_fails_release_and_stops(env, tmp_path, monkeypatch): + main, log, states = env["main"], env["log"], env["states"] + monkeypatch.setattr(prun, "dev_version", lambda _m: "") + states.write_text(json.dumps({"la": "running"})) + start(main) + rid = pl.send_release_request(main, "p1", "la", [10], [1], tmp_path / "st") + out = durable.output_of(pl.workflow_id("p1")) + assert out["status"] == "stopped" and out["not_stopped"] == [] and out["pending"] == [rid] + assert [(r["n"], r["state"]) for r in out["releases"]] == [(1, "failed")] and "出す版を決められない" in out["releases"][0]["reason"] + assert ["stop", "la", "--json"] in calls(log, "stop") and pl.read_answer(main, "p1", rid) == (None, None) + + +def test_char_rejected_settle_answers_with_reason(env, tmp_path): + main, states = env["main"], env["states"] + states.write_text(json.dumps({"la": "running"})) + start(main) + rid = pl.send_release_request(main, "p1", "la", [10], [1], tmp_path / "st") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release") + (prl.release_dir(main, "p1", 1, 1) / "presented.json").write_text(json.dumps({"sha": "sha1", "prs": [10]})) + resume(main, {"n": 1, "phase": "confirm"}, "c1") + ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "confirmed" and e) + assert ev["n"] == 1 and ev["k"] == 1 and "--role helper" in ev["spawn"] + resume(main, {"n": 1, "phase": "settle", "result": "rejected", "reason": "no"}, "s1") + ans, _ = until(lambda: pl.read_answer(main, "p1", rid)[0] and pl.read_answer(main, "p1", rid)) + assert ans == {"result": "rejected", "version": None, "release": 1, "reason": "no", "at": None} + out = stop(main) + rel = out["releases"][0] + assert (rel["state"], rel["reason"], rel["k"], rel["sha"], rel["prs"]) == ("rejected", "no", 1, "sha1", [10]) + assert out["pending"] == [] and event(main, "p1", "range-1") is None + + +def test_char_request_outside_range_is_deferred_to_next_release(env, tmp_path): + main, states = env["main"], env["states"] + states.write_text(json.dumps({"la": "running"})) + start(main) + r1 = pl.send_release_request(main, "p1", "la", [10], [1], tmp_path / "s1") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release") + r2 = pl.send_release_request(main, "p1", "la", [20], [2], tmp_path / "s2") + until(lambda: (event(main, "p1", "lane-la") or {}).get("received") == 2) + (prl.release_dir(main, "p1", 1, 1) / "presented.json").write_text(json.dumps({"sha": "sha1", "prs": [10]})) + resume(main, {"n": 1, "phase": "confirm"}, "c1") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "confirmed") + resume(main, {"n": 1, "phase": "settle", "result": "released", "version": "1.2.4"}, "s1") + ans, _ = until(lambda: pl.read_answer(main, "p1", r1)[0] and pl.read_answer(main, "p1", r1)) + assert (ans["result"], ans["version"], ans["release"]) == ("released", "1.2.4", 1) + assert event(main, "p1", "range-1") == {"sha": "sha1", "version": "1.2.4", "prs": [10], "lanes": ["la"]} + dfr = until(lambda: event(main, "p1", f"defer-{r2}")) + assert dfr == {"seq": 1, "reason": "範囲に入らないため次の配布へ回した", "at": None} + ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release" and e.get("n") == 2 and e) + assert ev["k"] == 1 and ev["represent"] is False + out = stop(main) + assert [(r["n"], r["state"]) for r in out["releases"]] == [(1, "released"), (2, "presented")] + assert out["releases"][1]["requests"] == [r2] and out["pending"] == [r2] From 0ab907afeab9053ce953bf88833af29eb28720e2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 05:44:43 +0000 Subject: [PATCH 14/70] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/scripts/lib/parallel=5Frun.py#parallel=5Fworkflo?= =?UTF-8?q?w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ループの中の停止処理・起動の対象の反映・終了判定・観測と知らせを _handle_stop・_spawn_ready・_finished・_observe へ抽出し、本体はループ制御と各段階の呼び出しだけにした。耐久ワークフローのステップの呼び出し順は変えていない。 Item-Id: I-001 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_run.py | 64 +++++++++++++++++-------- 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index f4745886d..eba3f7133 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -355,18 +355,13 @@ def parallel_workflow(main: str, name: str) -> dict: wait = poll_seconds() while True: if run.stop: - left = step_stop_lanes(main, [n for n, st in run.lanes.items() if st.get("started")]) - step_finish(main, name, run.progress()) - return {**run.summary("stopped"), "not_stopped": left} - ready = pp.ready_lanes(_plan_view(run), {n for n, st in run.lanes.items() if st.get("started")}, run.stopped()) - if ready: - for n, got in step_spawn(main, name, [ln.name for ln in ready]).items(): - run.lanes[n].update(got) + return _handle_stop(run, main) + _spawn_ready(run, main) if run.current is None and run.pending: run.answer_from_ranges() if run.pending: run.open_release() - if run.current is None and not run.pending and len(run.stopped()) == len(run.order): + if _finished(run): step_finish(main, name, run.progress()) return run.summary("finished") run.take(durable.recv("inbox", wait if run.current is None else min(wait, 30))) @@ -374,18 +369,47 @@ def parallel_workflow(main: str, name: str) -> dict: msg = durable.recv("resume", min(wait, 30)) if isinstance(msg, dict): run.resume(msg) - states = step_observe(main) - noticed = [] - for n in run.order: - st = states.get(n) - if st is None and run.lanes[n].get("started"): - st = "stopped" - if st and st != run.lanes[n].get("state"): - run.lanes[n]["state"] = st - if st == "awaiting_approval": - noticed.append(n) - if noticed: - run.pause("notice", lanes=noticed, attach=[f"python3 {RELAY} attach {n}" for n in noticed]) + _observe(run, main) + + +def _started(run: _Run) -> list[str]: + return [n for n, st in run.lanes.items() if st.get("started")] + + +def _handle_stop(run: _Run, main: str) -> dict: + """止める依頼を受けた後: 起動済みのレーンを止め、記録を閉じて要約を返す。""" + left = step_stop_lanes(main, _started(run)) + step_finish(main, run.name, run.progress()) + return {**run.summary("stopped"), "not_stopped": left} + + +def _spawn_ready(run: _Run, main: str) -> None: + """起動の条件がそろったレーンを起動し、結果をレーンの状態へ反映する。""" + ready = pp.ready_lanes(_plan_view(run), set(_started(run)), run.stopped()) + if ready: + for n, got in step_spawn(main, run.name, [ln.name for ln in ready]).items(): + run.lanes[n].update(got) + + +def _finished(run: _Run) -> bool: + """配布の途中でも残りでもなく、全レーンが止まっている。""" + return run.current is None and not run.pending and len(run.stopped()) == len(run.order) + + +def _observe(run: _Run, main: str) -> None: + """レーンの状態を観測して反映し、承認待ちへ移ったレーンを知らせる。""" + states = step_observe(main) + noticed = [] + for n in run.order: + st = states.get(n) + if st is None and run.lanes[n].get("started"): + st = "stopped" + if st and st != run.lanes[n].get("state"): + run.lanes[n]["state"] = st + if st == "awaiting_approval": + noticed.append(n) + if noticed: + run.pause("notice", lanes=noticed, attach=[f"python3 {RELAY} attach {n}" for n in noticed]) def _plan_view(run: _Run) -> pp.ParallelPlan: From 40afe498b699da0ed970d5d6f0f53bce08758360 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 05:46:02 +0000 Subject: [PATCH 15/70] =?UTF-8?q?Fix:=20=E6=A4=9C=E8=A8=BC=E3=81=AE?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=82=92=E7=9B=B4=E3=81=99=20=E2=80=94=20plu?= =?UTF-8?q?gins/ndf/scripts/lib/parallel=5Frun.py#parallel=5Fworkflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽出した _finished / _observe が他モジュールの同名関数と本体違いで構造検査(same-name)に 当たったため、_run_finished / _observe_run へ改名した。振る舞いは変えていない。 Item-Id: I-001 Impl-Runtime: claude Impl-Model: default --- plugins/ndf/scripts/lib/parallel_run.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index eba3f7133..a6807df40 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -361,7 +361,7 @@ def parallel_workflow(main: str, name: str) -> dict: run.answer_from_ranges() if run.pending: run.open_release() - if _finished(run): + if _run_finished(run): step_finish(main, name, run.progress()) return run.summary("finished") run.take(durable.recv("inbox", wait if run.current is None else min(wait, 30))) @@ -369,7 +369,7 @@ def parallel_workflow(main: str, name: str) -> dict: msg = durable.recv("resume", min(wait, 30)) if isinstance(msg, dict): run.resume(msg) - _observe(run, main) + _observe_run(run, main) def _started(run: _Run) -> list[str]: @@ -391,12 +391,12 @@ def _spawn_ready(run: _Run, main: str) -> None: run.lanes[n].update(got) -def _finished(run: _Run) -> bool: +def _run_finished(run: _Run) -> bool: """配布の途中でも残りでもなく、全レーンが止まっている。""" return run.current is None and not run.pending and len(run.stopped()) == len(run.order) -def _observe(run: _Run, main: str) -> None: +def _observe_run(run: _Run, main: str) -> None: """レーンの状態を観測して反映し、承認待ちへ移ったレーンを知らせる。""" states = step_observe(main) noticed = [] From 6f2c9897a985caefa31505f00c98c37f3c33373f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 05:47:37 +0000 Subject: [PATCH 16/70] =?UTF-8?q?Test:=20=E7=8F=BE=E7=8A=B6=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=B6=B3=E3=81=99?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/scripts/relay=5Flib/hub.py#Hub.proce?= =?UTF-8?q?ss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item-Id: I-002 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/tests/test_relay_hub.py | 132 ++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_relay_hub.py b/plugins/ndf/scripts/tests/test_relay_hub.py index a6bb39e21..a514b9edb 100644 --- a/plugins/ndf/scripts/tests/test_relay_hub.py +++ b/plugins/ndf/scripts/tests/test_relay_hub.py @@ -492,3 +492,135 @@ def test_no_tmux_runs_second_in_foreground(tmp_path): for t in (hub, second): if t is not None: t.close() + + +# ---------------------------------------------------------------- Hub.process の分岐(現状固定) + + +@pytest.fixture() +def launched(state, monkeypatch): + """tmux があるものとし、起動の呼び出しを記録する(起動は成功する)。""" + seen = [] + monkeypatch.setattr(hb.tmux, "binary", lambda: "/bin/true") + monkeypatch.setattr(hb.tmux, "sessions", lambda: {}) + monkeypatch.setattr(hb.tmux, "new_session", lambda name, cwd, argv, env: seen.append({"name": name, "cwd": cwd, "argv": argv, "env": env})) + hb.ensure_dirs() + return seen + + +def _write_registry(entries): + pathlib.Path(hb.hub_path(hb.REGISTRY)).write_text(json.dumps({"version": 1, "entries": entries})) + + +def test_process_unknown_kind_is_bad_request(launched): + res = hb.Hub(None).process({"kind": "nope", "name": "a"}) + assert res["tool"] == "relay-hub" and res["status"] == "stopped" + assert res["items"] == [{"name": "a", "result": "bad-request"}] and res["metrics"] == {"exit_code": 2} + assert "知らない依頼の種類: nope" in res["summary"] + assert launched == [] + + +def test_process_non_dict_request_shape_is_bad_request(launched): + res = hb.Hub(None).process({}) + assert res["items"] == [{"name": None, "result": "bad-request"}] and res["metrics"]["exit_code"] == 2 + + +@pytest.mark.parametrize("name", ["missing", None, 5]) +def test_process_restart_of_unknown_name_is_not_found(launched, name): + res = hb.Hub(None).process({"kind": "restart", "name": name}) + assert res["tool"] == "relay-restart" and res["status"] == "stopped" + assert res["items"][0]["result"] == hb.NOT_FOUND and res["metrics"]["exit_code"] == 3 + assert f"その名前は無い: {name}" in res["summary"] + assert launched == [] + + +def test_process_spawn_by_terminal_keeps_args_and_cwd(launched, tmp_path): + res = hb.Hub(None).process({"kind": "spawn", "name": "a", "cwd": str(tmp_path), "args": ["--model", "x", 3, "--", "y"], "by": "terminal"}) + assert res["tool"] == "relay-spawn" and res["status"] == "ok" and res["metrics"]["exit_code"] == 0 + assert res["items"] == [{"name": "a", "result": "started"}] + (call,) = launched + assert call["cwd"] == str(tmp_path) + assert call["argv"][-5:] == ["run", "--model", "x", "--", "y"] + assert call["env"] == {"NDF_RELAY_NAME": "a", "NDF_RELAY_SPAWNED": "1"} + row = hb.read_registry()["a"] + assert row["args"] == ["--model", "x", "--", "y"] and row["by"] == "terminal" and row["cwd"] == str(tmp_path) + assert "role" not in row and "plan" not in row + + +def test_process_spawn_joins_args_into_one_prompt(launched): + hb.Hub(None).process({"kind": "spawn", "name": "a", "cwd": "/", "args": ["--", "x", "y"], "by": "spawn"}) + hb.Hub(None).process({"kind": "spawn", "name": "b", "cwd": "/", "args": ["x", "y"], "by": "spawn"}) + assert [c["argv"][-2:] for c in launched] == [["--", "x y"], ["--", "x y"]] + + +def test_process_spawn_missing_cwd_falls_back_to_home(launched, tmp_path): + hb.Hub(None).process({"kind": "spawn", "name": "a", "cwd": str(tmp_path / "nope"), "args": [], "by": "spawn"}) + hb.Hub(None).process({"kind": "spawn", "name": "b", "cwd": 7, "by": "spawn"}) + assert [c["cwd"] for c in launched] == [os.path.expanduser("~")] * 2 + assert launched[0]["argv"][-1] == "run" + + +def test_process_spawn_without_name_takes_auto_name(launched, dirs): + dirs("front", name="t2") + res = hb.Hub(None).process({"kind": "spawn", "cwd": "/", "args": [], "by": "terminal"}) + assert res["items"] == [{"name": "t3", "result": "started"}] + assert launched[0]["name"] == "t3" + + +def test_process_spawn_helper_role_and_plan(launched): + res = hb.Hub(None).process({"kind": "spawn", "name": "h", "cwd": "/", "args": [], "by": "spawn", "role": "helper", "plan": "p1"}) + assert res["status"] == "ok" + assert launched[0]["env"] == {"NDF_RELAY_NAME": "h", "NDF_RELAY_SPAWNED": "1", "NDF_RELAY_ROLE": "helper", "NDF_RELAY_PLAN": "p1"} + row = hb.read_registry()["h"] + assert row["role"] == "helper" and row["plan"] == "p1" + + +def test_process_spawn_other_role_is_ignored(launched): + hb.Hub(None).process({"kind": "spawn", "name": "o", "cwd": "/", "args": [], "by": "spawn", "role": "organizer", "plan": "p1"}) + assert launched[0]["env"] == {"NDF_RELAY_NAME": "o", "NDF_RELAY_SPAWNED": "1"} + assert "role" not in hb.read_registry()["o"] + + +def test_process_spawn_refusal_from_judge_does_not_launch(launched): + res = hb.Hub(None).process({"kind": "spawn", "name": "Bad.Name", "cwd": "/", "args": [], "by": "spawn"}) + assert res["status"] == "stopped" and res["items"][0]["result"] == hb.BAD_NAME and res["metrics"]["exit_code"] == 3 + assert launched == [] and not os.path.exists(hb.hub_path(hb.REGISTRY)) + + +def test_process_tmux_failure_is_refused_and_registry_untouched(launched, monkeypatch): + monkeypatch.setattr(hb.tmux, "new_session", lambda name, cwd, argv, env: "duplicate session") + res = hb.Hub(None).process({"kind": "spawn", "name": "a", "cwd": "/", "args": [], "by": "spawn"}) + assert res["status"] == "stopped" and res["items"][0]["result"] == hb.TMUX_FAILED + assert res["metrics"]["exit_code"] == 1 and res["summary"] == "duplicate session" + assert not os.path.exists(hb.hub_path(hb.REGISTRY)) + + +def test_process_restart_uses_registry_row(launched, tmp_path): + row = {"session": "a", "cwd": str(tmp_path), "args": ["--", "最初"], "created_at": "x", "by": "spawn"} + _write_registry({"a": row}) + res = hb.Hub(None).process({"kind": "restart", "name": "a", "by": "restart"}) + assert res["tool"] == "relay-restart" and res["status"] == "ok" and res["items"] == [{"name": "a", "result": "started"}] + (call,) = launched + assert call["cwd"] == str(tmp_path) and call["argv"][-2:] == ["--", "最初"] + assert call["env"] == {"NDF_RELAY_NAME": "a", "NDF_RELAY_SPAWNED": "1"} + assert hb.read_registry()["a"]["by"] == "restart" + log = [json.loads(x) for x in pathlib.Path(hb.hub_path(hb.HUB_LOG)).read_text().splitlines()] + assert log[-1]["event"] == "restarted" and log[-1]["name"] == "a" + + +def test_process_restart_keeps_helper_role_and_registry_plan(launched, tmp_path): + row = {"session": "h", "cwd": str(tmp_path), "args": [], "created_at": "x", "by": "spawn", "role": "helper", "plan": "p9"} + _write_registry({"h": row}) + hb.Hub(None).process({"kind": "restart", "name": "h", "by": "restart"}) + assert launched[0]["env"]["NDF_RELAY_ROLE"] == "helper" and launched[0]["env"]["NDF_RELAY_PLAN"] == "p9" + # 依頼の plan は登録簿の plan より先に使う + hb.Hub(None).process({"kind": "restart", "name": "h", "by": "restart", "plan": "p10"}) + assert launched[1]["env"]["NDF_RELAY_PLAN"] == "p10" + assert hb.read_registry()["h"]["plan"] == "p10" + + +def test_process_restart_of_running_name_is_taken(launched, dirs, tmp_path): + dirs("d1", name="a") + _write_registry({"a": {"session": "a", "cwd": str(tmp_path), "args": [], "created_at": "x", "by": "spawn"}}) + res = hb.Hub(None).process({"kind": "restart", "name": "a"}) + assert res["items"][0]["result"] == hb.NAME_TAKEN and launched == [] From 90d07adf508936158a3f80784d3205f0a401e74c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 05:48:52 +0000 Subject: [PATCH 17/70] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/scripts/relay=5Flib/hub.py#Hub.process?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spawn の cwd と引数の正規化を同じモジュールの spawn_input(req) へ抜き出した。 restart_input と並ぶ入力の解釈で、process には観測・名前の採番・役割の選択・ judge・launch を残した。観測の順序・断りの JSON・登録簿を書き換える時機は変えていない。 Item-Id: I-002 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/relay_lib/hub.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/plugins/ndf/scripts/relay_lib/hub.py b/plugins/ndf/scripts/relay_lib/hub.py index 45613c8b5..2988e10fc 100644 --- a/plugins/ndf/scripts/relay_lib/hub.py +++ b/plugins/ndf/scripts/relay_lib/hub.py @@ -267,6 +267,17 @@ def restart_input(name: str, row: dict, w: Wrapper | None) -> tuple[str, list[st return cwd, args +def spawn_input(req: dict) -> tuple[str, list[str]]: + """新しく起こす (cwd, 引数)。cwd は無い・ディレクトリでなければホームへ寄せる。""" + cwd = req.get("cwd") if isinstance(req.get("cwd"), str) and os.path.isdir(req["cwd"]) else os.path.expanduser("~") + args = [a for a in req.get("args") or [] if isinstance(a, str)] + if req.get("by") != "terminal" and args: + # 端末で利用者が打った引数のほかは、`--` の後の最初の入力 1 つだけを受ける(選択肢を足させない)。 + # 受付箱は所有者だけが書けるため、`by` は信頼の境界でなく入口の区別である(I8) + args = prompt_args(" ".join(args[1:] if args[0] == "--" else args)) + return cwd, args + + # ---------------------------------------------------------------- 統括役 @@ -381,12 +392,7 @@ def process(self, req: dict) -> dict: cwd, args = restart_input(name, row, by_name(ws).get(name)) role = row.get("role") if row.get("role") == "helper" else None elif kind == "spawn": - cwd = req.get("cwd") if isinstance(req.get("cwd"), str) and os.path.isdir(req["cwd"]) else os.path.expanduser("~") - args = [a for a in req.get("args") or [] if isinstance(a, str)] - if req.get("by") != "terminal" and args: - # 端末で利用者が打った引数のほかは、`--` の後の最初の入力 1 つだけを受ける(選択肢を足させない)。 - # 受付箱は所有者だけが書けるため、`by` は信頼の境界でなく入口の区別である(I8) - args = prompt_args(" ".join(args[1:] if args[0] == "--" else args)) + cwd, args = spawn_input(req) if name is None: name = self.auto_name(now.taken) role = req.get("role") if req.get("role") == "helper" else None From 1c695f8e5fe3407bfc3282119098b32883c9f819 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 05:50:09 +0000 Subject: [PATCH 18/70] =?UTF-8?q?Fix:=20=E6=A4=9C=E8=A8=BC=E3=81=AE?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=82=92=E7=9B=B4=E3=81=99=20=E2=80=94=20plu?= =?UTF-8?q?gins/ndf/scripts/relay=5Flib/hub.py#Hub.process?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 現状固定テストの長い行を ruff format の形へ整形した(期待値は変えていない)。 Item-Id: I-002 Impl-Runtime: claude Impl-Model: default --- plugins/ndf/scripts/tests/test_relay_hub.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/scripts/tests/test_relay_hub.py b/plugins/ndf/scripts/tests/test_relay_hub.py index a514b9edb..063922725 100644 --- a/plugins/ndf/scripts/tests/test_relay_hub.py +++ b/plugins/ndf/scripts/tests/test_relay_hub.py @@ -503,7 +503,9 @@ def launched(state, monkeypatch): seen = [] monkeypatch.setattr(hb.tmux, "binary", lambda: "/bin/true") monkeypatch.setattr(hb.tmux, "sessions", lambda: {}) - monkeypatch.setattr(hb.tmux, "new_session", lambda name, cwd, argv, env: seen.append({"name": name, "cwd": cwd, "argv": argv, "env": env})) + monkeypatch.setattr( + hb.tmux, "new_session", lambda name, cwd, argv, env: seen.append({"name": name, "cwd": cwd, "argv": argv, "env": env}) + ) hb.ensure_dirs() return seen @@ -535,7 +537,9 @@ def test_process_restart_of_unknown_name_is_not_found(launched, name): def test_process_spawn_by_terminal_keeps_args_and_cwd(launched, tmp_path): - res = hb.Hub(None).process({"kind": "spawn", "name": "a", "cwd": str(tmp_path), "args": ["--model", "x", 3, "--", "y"], "by": "terminal"}) + res = hb.Hub(None).process( + {"kind": "spawn", "name": "a", "cwd": str(tmp_path), "args": ["--model", "x", 3, "--", "y"], "by": "terminal"} + ) assert res["tool"] == "relay-spawn" and res["status"] == "ok" and res["metrics"]["exit_code"] == 0 assert res["items"] == [{"name": "a", "result": "started"}] (call,) = launched From 8cb800603a8019beabf3eaf4d2a84405f6d0baaf Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 06:24:10 +0000 Subject: [PATCH 19/70] =?UTF-8?q?Fix:=20=E3=81=BE=E3=81=A8=E3=82=81?= =?UTF-8?q?=E3=81=9F=E9=85=8D=E5=B8=83=E3=81=AE=E6=8E=A5=E7=B6=9A=E3=81=AE?= =?UTF-8?q?=E8=AA=A4=E3=82=8A=E3=82=92=E7=9B=B4=E3=81=99=EF=BC=88PR=202452?= =?UTF-8?q?=20=E3=81=AE=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=20round=201?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wait: pause の action を ev から外してから返す(action の重なりで TypeError) - plan_parallel_release: apply_decls に kind・state を渡し、本番のプランへ提示の承認資料と 提示のプランの facts・答えの記録を渡す - present: prs.txt・approved.txt を提示のプランの状態ディレクトリ(state_dir_of)へ書く - レーンの配布の依頼: prs を省いた呼び出しは前のステージの PR({queue_prs})を載せる - 配布を組むたびに受けた依頼を releases/requests.json へ書く(契約 requests-json を足す) - closes_by_pr: PR ごとの gh pr view をまとめた読み出しへ置き換える - wait_from: DBOSClient を待ちの間 1 つだけ開く - released_version: 欠番で打ち切らず、配布の置き場のある連番をすべて読む Co-Authored-By: Claude Opus 5.5 --- .../plugins__ndf__scripts__parallel.py.json | 1 + .../plugins__ndf__scripts__supervise.py.json | 1 + .../ndf/contracts/files/requests-json.json | 7 ++ plugins/ndf/contracts/index.md | 1 + plugins/ndf/scripts/lib/durable.py | 11 +-- plugins/ndf/scripts/lib/parallel_lane.py | 27 ++++--- plugins/ndf/scripts/lib/parallel_release.py | 70 +++++++++++++++---- plugins/ndf/scripts/lib/parallel_run.py | 10 +-- plugins/ndf/scripts/parallel.py | 4 +- .../supervise_lib/release_templates.py | 3 +- plugins/ndf/scripts/supervise_lib/sprint.py | 5 +- .../ndf/scripts/tests/test_parallel_lane.py | 45 ++++++++++++ 12 files changed, 149 insertions(+), 36 deletions(-) create mode 100644 plugins/ndf/contracts/files/requests-json.json diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json index 911a87204..f6e58d92e 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json @@ -155,6 +155,7 @@ "prs-txt", "request-id-txt", "request-txt", + "requests-json", "run-log", "run-pid" ], diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json index b9eefc4ac..97922ec6b 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json @@ -78,6 +78,7 @@ "report-md", "request-id-txt", "request-txt", + "requests-json", "state-json" ], "reads": [], diff --git a/plugins/ndf/contracts/files/requests-json.json b/plugins/ndf/contracts/files/requests-json.json new file mode 100644 index 000000000..cf14d2975 --- /dev/null +++ b/plugins/ndf/contracts/files/requests-json.json @@ -0,0 +1,7 @@ +{ + "name": "requests-json", + "pattern": "requests.json", + "kind": "state", + "place": "main", + "skill_readers": [] +} diff --git a/plugins/ndf/contracts/index.md b/plugins/ndf/contracts/index.md index d4d2b8c14..76e32cb41 100644 --- a/plugins/ndf/contracts/index.md +++ b/plugins/ndf/contracts/index.md @@ -55,6 +55,7 @@ | `report-md` | `report.md` | report | state-dir | `plugins/ndf/scripts/supervise.py` | — | | `request-id-txt` | `request-id.txt` | state | state-dir | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | | `request-txt` | `request.txt` | result | state-dir | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | +| `requests-json` | `requests.json` | state | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | | `research-calls-jsonl` | `calls.jsonl` | record | main | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | | `research-item-json` | `*--*.item.json` | record | main | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | | `run-log` | `run.log` | report | main | `plugins/ndf/scripts/parallel.py` | — | diff --git a/plugins/ndf/scripts/lib/durable.py b/plugins/ndf/scripts/lib/durable.py index 09f38a648..aef1a51f6 100644 --- a/plugins/ndf/scripts/lib/durable.py +++ b/plugins/ndf/scripts/lib/durable.py @@ -435,8 +435,9 @@ def wait_from( names = list(keys) marks = {k: (after.get(k, 0) if isinstance(after, Mapping) else after) for k in names} deadline = None if timeout is None else time.monotonic() + timeout - while True: - with open_client(kind, identity) as c: + # 接続は待ちの間 1 つを使い回す(ポーリングごとに DBOSClient を作り直さない) + with open_client(kind, identity) as c: + while True: rows = c.list_workflows(workflow_ids=[wid], load_input=False, load_output=True) if not rows: return Outcome("error", f"耐久ワークフロー {wid} が無い") @@ -449,9 +450,9 @@ def wait_from( ev = c.get_event(wid, k, timeout_seconds=0) if isinstance(ev, dict) and int(ev.get("seq", 0)) > marks[k]: return Outcome("event", {"key": k, **ev}) - if deadline is not None and time.monotonic() >= deadline: - return Outcome("timeout") - time.sleep(poll) + if deadline is not None and time.monotonic() >= deadline: + return Outcome("timeout") + time.sleep(poll) def wait(wid: str, key: Optional[str] = None, after: int = 0, poll: float = POLL_SECONDS, timeout: Optional[float] = None) -> Outcome: diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py index 6aa8ba9e6..b1b6f2a6a 100644 --- a/plugins/ndf/scripts/lib/parallel_lane.py +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -90,19 +90,26 @@ def merged_on_main(root: Path, prs: list[int], run=subprocess.run) -> bool: def released_version(main: Path, name: str, prs: list[int]) -> Optional[str]: - """`prs` を含む範囲のイベント `range-<連番>` の版(無ければ None)。""" + """`prs` を含む範囲のイベント `range-<連番>` の版(無ければ None)。 + + `range-<連番>` は出せた配布にだけあり、差し戻し・失敗の配布の連番は欠ける。欠番で打ち切らず、配布の置き場 + `releases/<連番>/` のある連番をすべて読む。""" import durable - ident, wid = record_identity(main, name), workflow_id(name) - n = 1 + rel = pp.plan_dir(main, name) / "releases" + try: + seqs = sorted(int(d.name) for d in rel.iterdir() if d.is_dir() and d.name.isdigit()) + except OSError: + seqs = [] + if not seqs: + return None found = None - while True: - ev = durable.event_from(KIND, ident, wid, f"range-{n}", 0) - if not isinstance(ev, dict): - return found - if set(prs) & set(ev.get("prs") or []): - found = ev.get("version") - n += 1 + with durable.open_client(KIND, record_identity(main, name)) as c: + for n in seqs: + ev = c.get_event(workflow_id(name), f"range-{n}", timeout_seconds=0) + if isinstance(ev, dict) and set(prs) & set(ev.get("prs") or []): + found = ev.get("version") + return found def lane_request_id(state: Path, lane: str) -> str: diff --git a/plugins/ndf/scripts/lib/parallel_release.py b/plugins/ndf/scripts/lib/parallel_release.py index 17c6ac5f8..913569118 100644 --- a/plugins/ndf/scripts/lib/parallel_release.py +++ b/plugins/ndf/scripts/lib/parallel_release.py @@ -73,8 +73,16 @@ def breakdown_md(mapped: dict, added: Optional[dict] = None) -> str: return "\n".join(lines) + "\n" -def closes_by_pr(root: Path, prs: list[int], run: Callable = subprocess.run) -> dict[int, list[int]]: - """Pull Request が閉じる課題(`closingIssuesReferences`)。読めないものは空にする。""" +def closes_by_pr(root: Path, prs: list[int], run: Callable = subprocess.run, bulk: Optional[Callable] = None) -> dict[int, list[int]]: + """Pull Request が閉じる課題(`closingIssuesReferences`)。読めないものは空にする。 + + まとめて読む(`release_lib.included.pr_fields`。100 本ごとに 1 回)。読めなければ Pull Request ごとの `gh pr view` へ倒す。""" + if prs: + if bulk is None: + from release_lib.included import pr_fields as bulk + got, _ = bulk(prs, cwd=str(root)) + if got is not None: + return {n: [int(i["number"]) for i in (got.get(n) or {}).get("closingIssuesReferences") or [] if "number" in i] for n in prs} out: dict[int, list[int]] = {} for n in prs: got = run( @@ -102,7 +110,7 @@ def present( """承認資料から提示したコミットと含む Pull Request を読み、レーンへ対応づける。 書くのは提示の置き場 `d`(`releases/<連番>/<提示>/`)の決まった名前だけである: `presented.json`、承認資料へ足す - 内訳 `breakdown.md`、提示のプランの状態ディレクトリの `present/work/prs.txt`・`present/work/approved.txt` + 内訳 `breakdown.md`、提示のプランの状態ディレクトリ(`present_state_dir`)の `work/prs.txt`・`work/approved.txt` (本番のプランが `{queue_prs:present}`・`{queue_approved:present}` で読む)。承認資料へ内訳を足すのはプランのコマンドである。 `first` は出し直しのときの提示 1 の `presented.json`(加わった範囲を載せる)。承認資料に承認したコミットが無ければ ValueError。""" import approved_commit @@ -118,14 +126,29 @@ def present( added = map_lanes(plan, extra, closes, requests) data = {"sha": sha, "prs": prs, "lanes": mapped["lanes"], "orphans": mapped["orphans"], "added": added} d = Path(d) - (d / "present" / "work").mkdir(parents=True, exist_ok=True) + work = present_state_dir(d) / "work" + work.mkdir(parents=True, exist_ok=True) (d / "presented.json").write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") (d / "breakdown.md").write_text(breakdown_md(mapped, added), encoding="utf-8") - (d / "present" / "work" / "prs.txt").write_text(" ".join(str(n) for n in prs) + "\n", encoding="utf-8") - (d / "present" / "work" / "approved.txt").write_text(sha + "\n", encoding="utf-8") + (work / "prs.txt").write_text(" ".join(str(n) for n in prs) + "\n", encoding="utf-8") + (work / "approved.txt").write_text(sha + "\n", encoding="utf-8") return data +def present_state_dir(d: Path) -> Path: + """提示のプラン `/present.json` の状態ディレクトリ(queue と `{queue_prs:present}` が読む `state_dir_of` の置き場)。""" + from supervise_lib.paths import state_dir_of + + return state_dir_of(str(Path(d).absolute() / "present.json")) + + +def write_requests(main: Path, name: str, requests: list[dict]) -> None: + """耐久ワークフローが受けた配布の依頼を `releases/requests.json` へ書く(`parallel.py present` が内訳に使う)。""" + rel = pp.plan_dir(main, name) / "releases" + rel.mkdir(parents=True, exist_ok=True) + (rel / "requests.json").write_text(json.dumps(requests, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + def release_dir(main: Path, name: str, seq: int, k: int) -> Path: return pp.plan_dir(main, name) / "releases" / str(seq) / str(k) @@ -234,17 +257,19 @@ def plan_parallel_release(main, name: str, d, version: str) -> tuple[dict, dict] 提示のプランは開発版の承認資料のステップ(approval-facts・承認資料の説明の欄・他のプラグインの上げ幅)を版上げ無しで 流し、`parallel.py present` でレーンの内訳を足し、最後に承認ゲート 2(MVV 判定 → approve)を置く。本番のプランは - 承認ゲート 2 を持たず、確定した提示のプランの承認したコミット(`{queue_approved:present}`)から `release/v<版>` を切る。""" - from supervise_lib.decl import apply_decls + 承認ゲート 2 を持たず、確定した提示のプランの承認したコミット(`{queue_approved:present}`)から `release/v<版>` を切る。 + 本番のプランの承認資料はこの提示の承認資料で、先頭の approve は提示のプランの facts と答えの記録を読む。""" + from supervise_lib.decl import apply_decls, decl_fields from supervise_lib.paths import PARALLEL_PY from supervise_lib.procedures import with_record - from supervise_lib.release_templates import _dev_approval_steps - from supervise_lib.sprint_waves import plan_mvv_release + from supervise_lib.release_templates import _dev_approval_steps, plan_release, release_names repo = str(main) d = Path(d) seq, k = d.parent.name, d.name ns = argparse.Namespace( + kind="release", + state=None, worktree=repo, repo=repo, base=None, @@ -278,7 +303,7 @@ def plan_parallel_release(main, name: str, d, version: str) -> tuple[dict, dict] plugin = (ns.release or {}).get("plugin", "ndf") approval = f"issues/approval-{plugin}-v{version}-parallel-{seq}-{k}.md" state = d / "answer.json" - present_state = d / "present" # 提示のプランの状態ディレクトリ(`state_dir_of` の置き場と同じ) + present_state = present_state_dir(d) # 提示のプランの状態ディレクトリ(queue の `state_dir_of` と同じ置き場) facts = _dev_approval_steps(ns, repo, approval, "0", ",".join((ns.release or {}).get("runtimes") or [])) facts[-1]["next"] = "present" present = { @@ -303,7 +328,28 @@ def plan_parallel_release(main, name: str, d, version: str) -> tuple[dict, dict] "steps": [*facts, present, *gate], } ) - release_plan = plan_mvv_release(ns, repo, version, "prod", prs=["{queue_prs:present}"]) + wt, branch = release_names(version) + release_plan = plan_release( + argparse.Namespace( + **decl_fields(ns), + version=version, + level=None, + series=None, + prs=["{queue_prs:present}"], + prs_from_queue=False, + channel="prod", + repo=repo, + prev_tag=None, + worktree=f"{repo}/{wt}", + branch=branch, + issue=[], + mode="standard", + mvv=None, + state=str(state), + dev_state=str(present_state), + approval_path=approval, + ) + ) release_plan["起点"] = "{queue_approved:present}" release_plan["並列の計画"] = name return present_plan, release_plan diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index a6807df40..17e7a598f 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -134,8 +134,10 @@ def step_observe(main: str) -> dict: @durable.step(name="parallel.build") -def step_build(main: str, name: str, seq: int, k: int) -> dict: - """まとめた配布の提示のプランと本番のプランを書く。""" +def step_build(main: str, name: str, seq: int, k: int, requests: Optional[list[dict]] = None) -> dict: + """まとめた配布の提示のプランと本番のプランを書き、受けている依頼を `releases/requests.json` へ書く + (`parallel.py present` が耐久ワークフローの外からレーンの内訳に読む)。""" + pr.write_requests(Path(main), name, list(requests or [])) if seq > pr.MAX_HELPER_SEQ: return {"error": f"配布の連番 {seq} が一時レーンの名前の上限 {pr.MAX_HELPER_SEQ} を超える"} version = dev_version(Path(main)) @@ -252,7 +254,7 @@ def answer_from_ranges(self) -> None: def open_release(self) -> None: """流れている配布が無く依頼があれば、他のレーンを待たずに提示 1 を組む(I9)。""" n = len(self.releases) + 1 - built = step_build(self.main, self.name, n, 1) + built = step_build(self.main, self.name, n, 1, self.pending) if built.get("error"): self.stop = True self.releases.append({"n": n, "state": "failed", "reason": built["error"]}) @@ -295,7 +297,7 @@ def confirm(self, rel: dict, take: Optional[str]) -> None: first = step_presented(rel["dirs"][0]) head = step_head(self.main) if pr.moved(first.get("sha") or "", head): - built = step_build(self.main, self.name, rel["n"], 2) + built = step_build(self.main, self.name, rel["n"], 2, self.pending) rel["dirs"].append(built["dir"]) self.emit_release(rel, 2, represent=True) return diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index e4637507c..0e6500778 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -192,7 +192,7 @@ def cmd_wait(a) -> None: if got.kind == "event": ev = dict(got.value) key = ev.pop("key") - action = ev.get("action") if key == "pause" else "questions" + action = ev.pop("action", None) if key == "pause" else "questions" # reply の action と重ねない if action == "running": reply("stopped", "まだ変化が無い", code=3, seq=ev.get("seq")) items = ev.pop("items", None) or [] @@ -231,7 +231,7 @@ def cmd_present(a) -> None: def _requests_for(main: Path, name: str) -> list[dict]: - """オーガナイザーが読める依頼(`releases/requests.json`。耐久ワークフローの外からは依頼の一覧を読めないため、無ければ空)。""" + """オーガナイザーが読める依頼(`releases/requests.json`。耐久ワークフローが配布を組むたびに書く。無ければ空)。""" p = pp.plan_dir(main, name) / "releases" / "requests.json" try: return json.loads(p.read_text(encoding="utf-8")) diff --git a/plugins/ndf/scripts/supervise_lib/release_templates.py b/plugins/ndf/scripts/supervise_lib/release_templates.py index f3a7c283e..eee63db03 100644 --- a/plugins/ndf/scripts/supervise_lib/release_templates.py +++ b/plugins/ndf/scripts/supervise_lib/release_templates.py @@ -359,7 +359,8 @@ def plan_release_package_plugin(a) -> dict: + ["release", "verify"] + (["facts", "explain", "others"] if dev else []) ) - approval = f"issues/approval-{plugin}-v{base}.md" + # まとめた配布(#2436)の本番は、提示で作って承認した資料を渡す + approval = getattr(a, "approval_path", None) or f"issues/approval-{plugin}-v{base}.md" steps = _prepare_steps(a, plugin, v, prs, dev, after_notes, repo, approval) ref = a.base if dev else a.production_branch if sync: diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index 66b91c363..b128a0c1c 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -32,7 +32,7 @@ from supervise_lib.paths import CHECK_PY, SELF from supervise_lib.procedures import sprint_out, with_record from supervise_lib.release_templates import release_verify_step, strip_suffix -from supervise_lib.plan import sprint_state_path +from supervise_lib.plan import QUEUE_PRS, sprint_state_path from supervise_lib.sprint_routes import ( normal_gate_1, normal_gate_2, @@ -66,7 +66,8 @@ def prod_plans(a, repo: str, build, prs: list[str] | None = None) -> dict: if lane: from parallel_release import plan_release_request - return {"release-request": plan_release_request(a, repo, lane[0], lane[1], prs)} + # prs を省いた呼び出しは、前のステージの Pull Request のすべて({queue_prs})を依頼に載せる + return {"release-request": plan_release_request(a, repo, lane[0], lane[1], prs or [QUEUE_PRS])} return {"release-prod": build()} diff --git a/plugins/ndf/scripts/tests/test_parallel_lane.py b/plugins/ndf/scripts/tests/test_parallel_lane.py index 89af781ec..50a1d7db1 100644 --- a/plugins/ndf/scripts/tests/test_parallel_lane.py +++ b/plugins/ndf/scripts/tests/test_parallel_lane.py @@ -80,3 +80,48 @@ def test_present_gate_puts_approve_after_mvv(tmp_path): steps = parallel_release.present_gate_steps(str(tmp_path / "a.json"), "/r", "issues/x.md", "1 2", "standard", "/f", "/a") ids = [s["id"] for s in steps] assert ids[:3] == ["mvv", "note", "approve"] and steps[1]["next"] == "approve" and steps[2]["next"] == "end" + + +def test_lane_request_without_prs_uses_previous_stage_prs(monkeypatch): + from supervise_lib import sprint + + import parallel_release + + seen = {} + monkeypatch.setattr(sprint, "parallel_lane", lambda repo: ("p1", "la")) + monkeypatch.setattr(parallel_release, "plan_release_request", lambda a, repo, plan, lane, prs=None: seen.setdefault("prs", prs)) + sprint.prod_plans(None, "/r", lambda: {}) + assert seen["prs"] == ["{queue_prs}"] + + +def test_closes_by_pr_reads_in_bulk(tmp_path): + import supervise_lib # noqa: F401 + import parallel_release + + calls = [] + + def bulk(prs, cwd=None): + calls.append(list(prs)) + return {1: {"closingIssuesReferences": [{"number": 7}]}, 2: None}, "" + + def per_pr(*a, **k): + raise AssertionError("PR ごとに gh を起動しない") + + assert parallel_release.closes_by_pr(tmp_path, [1, 2], run=per_pr, bulk=bulk) == {1: [7], 2: []} + assert calls == [[1, 2]] + + +def test_present_writes_to_present_plan_state_dir(tmp_path, monkeypatch): + import supervise_lib # noqa: F401 + import approved_commit + import parallel_release + from release_lib import included + from supervise_lib.paths import state_dir_of + + monkeypatch.setattr(approved_commit, "material_sha", lambda p: "abc") + monkeypatch.setattr(included, "read_included", lambda p: [5]) + plan = pp.ParallelPlan.model_validate(PLAN, context={"max_plans": 10, "max_named": 10}) + d = tmp_path / "releases" / "1" / "1" + parallel_release.present(tmp_path, plan, tmp_path / "a.md", [], d, closes_of=lambda r, prs: {}) + work = state_dir_of(str(d / "present.json")) / "work" + assert (work / "prs.txt").read_text().strip() == "5" and (work / "approved.txt").read_text().strip() == "abc" From e0e300872d3ac2f5d54dd207d5ebfeef873f6b76 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 07:03:04 +0000 Subject: [PATCH 20/70] =?UTF-8?q?fix(parallel):=20=E6=9C=AC=E7=95=AA?= =?UTF-8?q?=E3=81=AE=E9=85=8D=E5=B8=83=E3=81=AE=E9=80=94=E4=B8=AD=E3=81=AE?= =?UTF-8?q?=20stop=20=E3=81=AF=20settle=20=E3=82=92=E5=BE=85=E3=81=A1?= =?UTF-8?q?=E3=80=81=E8=B5=B7=E5=8B=95=E3=81=AE=E6=8C=87=E7=A4=BA=E3=82=92?= =?UTF-8?q?=E7=9F=A5=E3=82=89=E3=81=9B=E3=81=AB=E6=B7=BB=E3=81=88=E3=80=81?= =?UTF-8?q?=E3=83=AC=E3=83=BC=E3=83=B3=E3=81=AE=E6=9C=AC=E7=95=AA=E3=82=92?= =?UTF-8?q?=E5=89=8D=E3=81=AE=E3=82=B9=E3=83=86=E3=83=BC=E3=82=B8=E3=81=B8?= =?UTF-8?q?=E3=81=A4=E3=81=AA=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parallel_run: 承認後の配布(confirmed)の途中に stop を受けたら settle が届くまで耐久ワークフローを終えない(設計の失敗の 12 番) - parallel_run: イベント pause の上書きで release / confirmed の起動の指示を失わないよう、配布の途中の notice に最後の起動の指示を添える - sprint: 並列のレーンの「本番」ステージに then_of を付け、{queue_prs} を前のステージの Pull Request で埋める Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_run.py | 21 +++++++++++-- plugins/ndf/scripts/supervise_lib/sprint.py | 3 +- .../ndf/scripts/tests/test_parallel_lane.py | 18 +++++++++++ .../ndf/scripts/tests/test_parallel_run.py | 31 +++++++++++++++++++ .../references/parallel.md | 2 +- 5 files changed, 70 insertions(+), 5 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 17e7a598f..7ede6518b 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -5,7 +5,8 @@ - メッセージ `inbox`: `{kind: request | gate1 | gate2 | ask | stop, ...}`(レーン・一時レーン・`stop`) - メッセージ `resume`: `{n, phase: confirm, take}` / `{n, phase: settle, result, version, reason}`(`confirm`・`settle`) -- イベント `pause`: `{seq, action}`(`running` / `notice` / `release` / `confirmed`)、`questions`: `{seq, items}` +- イベント `pause`: `{seq, action}`(`running` / `notice` / `release` / `confirmed`。配布の途中の `notice` は + 最後の起動の指示を `release` に添える)、`questions`: `{seq, items}` - イベント `answer-<依頼>`(最終の答え。1 回だけ)・`defer-<依頼>`(次の配布へ回した知らせ)・`range-<連番>`・`lane-<名>` 統括役の CLI(`relay.py spawn`・`ls`・`stop`・`role`)は子プロセスで打ち、統一 JSON レポートだけを読む。 @@ -201,6 +202,7 @@ def __init__(self, main: str, name: str, lanes: list[dict]): self.current: Optional[dict] = None self.stop = False self.seq = 0 + self.launch: Optional[dict] = None self.received: dict[str, int] = {} def bump(self) -> int: @@ -208,7 +210,14 @@ def bump(self) -> int: return self.seq def pause(self, action: str, **fields) -> None: - durable.set_event("pause", {"seq": self.bump(), "action": action, **fields}) + """イベント `pause` は上書きされるため、流れている配布の最後の起動の指示(release / confirmed)を + 他の知らせにも `release` として添え、知らせが重なっても起動の指示を失わない。""" + seq = self.bump() + if action in ("release", "confirmed"): + self.launch = {"seq": seq, "action": action, **fields} + elif self.current is not None and self.launch: + fields = {**fields, "release": self.launch} + durable.set_event("pause", {"seq": seq, "action": action, **fields}) def progress(self) -> dict: return {"lanes": self.lanes, "releases": [{k: v for k, v in r.items() if k != "presented"} for r in self.releases]} @@ -356,8 +365,14 @@ def parallel_workflow(main: str, name: str) -> dict: run.pause("running") wait = poll_seconds() while True: - if run.stop: + releasing = run.current is not None and run.current.get("state") == "confirmed" + if run.stop and not releasing: return _handle_stop(run, main) + if run.stop: # 本番の配布の途中の stop は settle が届くまで待ってから終える(設計の失敗の 12 番。作りかけを残さない) + msg = durable.recv("resume", min(wait, 30)) + if isinstance(msg, dict): + run.resume(msg) + continue _spawn_ready(run, main) if run.current is None and run.pending: run.answer_from_ranges() diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index b128a0c1c..7d1e21027 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -314,7 +314,8 @@ def then(name: str) -> dict: return waves + [release] prod = prod_version(a.version) if parallel_lane(repo): # レーンは承認ゲート 2 を置かず、配布の依頼を送る(承認ゲート 2 はオーガナイザーが出す。#2436 の I17) - return waves + [release, {"name": "本番", "plans": prod_plans(a, repo, lambda: {})}] + # 本番は配布の後へ --then でつなぎ、{queue_prs} を前のステージの Pull Request で埋める + return waves + [release, {"name": "本番", "plans": prod_plans(a, repo, lambda: {}), "then_of": first}] return waves + [ release, {"name": GATE_2, "gate": normal_gate_2_stage(a, repo, prod, advise)}, diff --git a/plugins/ndf/scripts/tests/test_parallel_lane.py b/plugins/ndf/scripts/tests/test_parallel_lane.py index 50a1d7db1..c940afe9d 100644 --- a/plugins/ndf/scripts/tests/test_parallel_lane.py +++ b/plugins/ndf/scripts/tests/test_parallel_lane.py @@ -125,3 +125,21 @@ def test_present_writes_to_present_plan_state_dir(tmp_path, monkeypatch): parallel_release.present(tmp_path, plan, tmp_path / "a.md", [], d, closes_of=lambda r, prs: {}) work = state_dir_of(str(d / "present.json")) / "work" assert (work / "prs.txt").read_text().strip() == "5" and (work / "approved.txt").read_text().strip() == "abc" + + +def test_lane_prod_stage_chains_after_release(monkeypatch, tmp_path): + import argparse + + from supervise_lib import sprint + + monkeypatch.setattr(sprint, "parallel_lane", lambda repo: ("p1", "la")) + monkeypatch.setattr(sprint, "has_release_template", lambda a: True) + monkeypatch.setattr(sprint, "has_prod_branch", lambda a: True) + monkeypatch.setattr(sprint, "design_read", lambda a: False) + monkeypatch.setattr(sprint, "advises", lambda a: False) + for f in ("plan_sprint_branch", "plan_sprint_impl", "plan_sprint_check", "plan_sprint_release"): + monkeypatch.setattr(sprint, f, lambda *a, **k: {}) + monkeypatch.setattr(sprint, "prod_plans", lambda a, repo, build, prs=None: {"release-request": {}}) + a = argparse.Namespace(worktree=str(tmp_path), design=[], issue=[1], version=None, pace="normal") + waves = sprint.sprint_plans(a) + assert waves[-1]["name"] == "本番" and waves[-1]["then_of"] == waves[0]["name"] # 先頭の command へ --then でつなぐ diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index 0a71a5ef9..031f647cf 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -284,3 +284,34 @@ def test_char_request_outside_range_is_deferred_to_next_release(env, tmp_path): out = stop(main) assert [(r["n"], r["state"]) for r in out["releases"]] == [(1, "released"), (2, "presented")] assert out["releases"][1]["requests"] == [r2] and out["pending"] == [r2] + + +def test_stop_during_release_waits_for_settle(env, tmp_path): + main, states = env["main"], env["states"] + states.write_text(json.dumps({"la": "running"})) + start(main) + rid = pl.send_release_request(main, "p1", "la", [10], [1], tmp_path / "st") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release") + (prl.release_dir(main, "p1", 1, 1) / "presented.json").write_text(json.dumps({"sha": "sha1", "prs": [10]})) + resume(main, {"n": 1, "phase": "confirm"}, "c1") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "confirmed") + wid = pl.workflow_id("p1") + durable.send_to(pl.KIND, pl.record_identity(main, "p1"), wid, {"kind": "stop"}, "inbox", f"{wid}:inbox:stop") + time.sleep(0.8) + assert durable.status(wid) not in ("SUCCESS",) # 配布の途中の stop は settle まで終わらない + resume(main, {"n": 1, "phase": "settle", "result": "released", "version": "1.2.3"}, "s1") + out = durable.output_of(wid) + assert out["status"] == "stopped" and out["releases"][0]["state"] == "released" + assert pl.read_answer(main, "p1", rid)[0]["result"] == "released" + + +def test_notice_during_release_carries_launch(env, tmp_path): + main, states = env["main"], env["states"] + states.write_text(json.dumps({"la": "running"})) + start(main) + pl.send_release_request(main, "p1", "la", [10], [1], tmp_path / "st") + rel = until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release" and e) + states.write_text(json.dumps({"la": "awaiting_approval"})) + ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "notice" and e) + assert ev["release"]["seq"] == rel["seq"] and ev["release"]["spawn"] == rel["spawn"] + assert ev["release"]["pipeline"] == rel["pipeline"] diff --git a/plugins/ndf/skills/development-workflow/references/parallel.md b/plugins/ndf/skills/development-workflow/references/parallel.md index 8716c7598..8fa6beda8 100644 --- a/plugins/ndf/skills/development-workflow/references/parallel.md +++ b/plugins/ndf/skills/development-workflow/references/parallel.md @@ -21,7 +21,7 @@ | `wait` が返したもの | すること | | --- | --- | -| `notice` | 承認待ちのレーンの `metrics.attach` の 1 行を出す | +| `notice` | 承認待ちのレーンの `metrics.attach` の 1 行を出す。`metrics.release` があり、その `seq` の起動をまだ打っていなければ、`metrics.release.spawn` の 1 行も打つ(知らせが重なって `release` / `confirmed` を読み逃しても起動を失わない) | | `questions` | 届いている依頼(設計の承認・承認ゲート 2・承認ゲートの外の問い)を区分ごとにまとめて 1 回で問う | | `release` / `confirmed` | `metrics.spawn` の 1 行(一時レーンの起動)を打つ。一時レーンが `metrics.pipeline` を流し、結果を返す | | `finished` / `stopped` | レーンごとの結果(配布した版・止まった理由)を要約し、`[ndf-parallel] finished p1` の行を出して終える | From 2cc870a29767e9d077eed6cc6e0136ac7ddd881a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 07:19:07 +0000 Subject: [PATCH 21/70] =?UTF-8?q?fix(parallel):=20=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E3=81=AE=E3=81=BE=E3=81=BE=E6=B5=81=E3=82=8C=E3=82=8B=E9=85=8D?= =?UTF-8?q?=E5=B8=83=E3=81=AE=E9=80=94=E4=B8=AD=E3=81=AE=20stop=20?= =?UTF-8?q?=E3=82=82=20settle=20=E3=82=92=E5=BE=85=E3=81=A1=E3=80=81wait?= =?UTF-8?q?=20=E3=81=AF=20seq=20=E3=81=AE=E5=B0=8F=E3=81=95=E3=81=84?= =?UTF-8?q?=E3=82=A4=E3=83=99=E3=83=B3=E3=83=88=E3=81=8B=E3=82=89=E8=BF=94?= =?UTF-8?q?=E3=81=97=E3=80=81=E6=8F=90=E7=A4=BA=E3=81=AE=E3=83=97=E3=83=A9?= =?UTF-8?q?=E3=83=B3=E3=82=92=E5=A7=8B=E3=82=81=E3=82=89=E3=82=8C=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parallel_run: stop を保留する条件を run.current がある間へ広げる(設計の失敗の 12 番) - durable.wait_from: after を超えたイベントが複数あれば seq の最も小さいものを返す - parallel_release: 提示のプランの facts・explain・others から on_fail(無い judge)を外し、 facts に gate_as_ok を付け、--prs は "0" ではなく値なしで渡す - release-steps: approval-facts・notes・changed-plugins の --prs で値なしを受ける Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/durable.py | 8 +++++- plugins/ndf/scripts/lib/parallel_release.py | 6 ++++- plugins/ndf/scripts/lib/parallel_run.py | 6 ++--- plugins/ndf/scripts/release-steps.py | 10 +++---- .../ndf/scripts/tests/test_parallel_lane.py | 14 ++++++++++ .../ndf/scripts/tests/test_parallel_run.py | 27 +++++++++++++++++-- 6 files changed, 59 insertions(+), 12 deletions(-) diff --git a/plugins/ndf/scripts/lib/durable.py b/plugins/ndf/scripts/lib/durable.py index aef1a51f6..01b32e73f 100644 --- a/plugins/ndf/scripts/lib/durable.py +++ b/plugins/ndf/scripts/lib/durable.py @@ -430,6 +430,7 @@ def wait_from( poll: float = 1.0, ) -> Outcome: """`wait` と同じ判定を外から行う。終わりか、`keys` のどれかのイベントの `seq` が `after` を超えるまで待つ。 + 超えたイベントが複数あれば `seq` の最も小さいものを返す(`keys` の `seq` は同じ連番を共有する前提)。 `Outcome("event", {"key": <イベント名>, **値})`・`Outcome("done", 出力)`・`Outcome("timeout")` などを返す。""" names = list(keys) @@ -446,10 +447,15 @@ def wait_from( return Outcome("done", rows[0].output) if st in FINISHED: return Outcome("cancelled" if st == "CANCELLED" else "error", st) + # 超えたイベントが複数あれば seq の小さいものから返す(`--after ` の打ち直しで先のイベントを読み飛ばさない) + hits = [] for k in names: ev = c.get_event(wid, k, timeout_seconds=0) if isinstance(ev, dict) and int(ev.get("seq", 0)) > marks[k]: - return Outcome("event", {"key": k, **ev}) + hits.append((int(ev.get("seq", 0)), k, ev)) + if hits: + _, k, ev = min(hits, key=lambda h: h[0]) + return Outcome("event", {"key": k, **ev}) if deadline is not None and time.monotonic() >= deadline: return Outcome("timeout") time.sleep(poll) diff --git a/plugins/ndf/scripts/lib/parallel_release.py b/plugins/ndf/scripts/lib/parallel_release.py index 913569118..8f77b05ba 100644 --- a/plugins/ndf/scripts/lib/parallel_release.py +++ b/plugins/ndf/scripts/lib/parallel_release.py @@ -304,7 +304,11 @@ def plan_parallel_release(main, name: str, d, version: str) -> tuple[dict, dict] approval = f"issues/approval-{plugin}-v{version}-parallel-{seq}-{k}.md" state = d / "answer.json" present_state = present_state_dir(d) # 提示のプランの状態ディレクトリ(queue の `state_dir_of` と同じ置き場) - facts = _dev_approval_steps(ns, repo, approval, "0", ",".join((ns.release or {}).get("runtimes") or [])) + # 足す PR は無い(範囲の PR だけを集める。`--prs` を値なしで渡す) + facts = _dev_approval_steps(ns, repo, approval, "", ",".join((ns.release or {}).get("runtimes") or [])) + for s in facts: + s.pop("on_fail", None) # 提示のプランに judge は無い。落ちたらそのステップで止まる + facts[0]["gate_as_ok"] = True # facts の 10 は関門にせず、承認の成否は後段の mvv・approve が決める facts[-1]["next"] = "present" present = { "id": "present", diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 7ede6518b..b79491538 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -365,10 +365,10 @@ def parallel_workflow(main: str, name: str) -> dict: run.pause("running") wait = poll_seconds() while True: - releasing = run.current is not None and run.current.get("state") == "confirmed" - if run.stop and not releasing: + if run.stop and run.current is None: return _handle_stop(run, main) - if run.stop: # 本番の配布の途中の stop は settle が届くまで待ってから終える(設計の失敗の 12 番。作りかけを残さない) + if run.stop: # 一時レーンが配布を流している間(提示のまま MVV 判定で本番へ進む場合も含む)の stop は、 + # settle が届くまで待ってから終える(設計の失敗の 12 番。作りかけを残さない) msg = durable.recv("resume", min(wait, 30)) if isinstance(msg, dict): run.resume(msg) diff --git a/plugins/ndf/scripts/release-steps.py b/plugins/ndf/scripts/release-steps.py index 3bb1c202e..a26667517 100755 --- a/plugins/ndf/scripts/release-steps.py +++ b/plugins/ndf/scripts/release-steps.py @@ -484,7 +484,7 @@ def cmd_changed_plugins(a): if a.decided and pending: decided = changed.approval_path(root, a.decided) rows = changed.decided_rows(decided, pending, already) - elif a.prs: # 候補は範囲の PR と --prs の和の本文から出す(#1885。develop へ直接入った互換の無い変更を拾う) + elif a.prs is not None: # 候補は範囲の PR と --prs の和の本文から出す(#1885。develop へ直接入った互換の無い変更を拾う) prs = included.included_prs(included.release_range(root, f"refs/tags/{since}", "HEAD"), a.prs) data = included.pr_fields(prs, cwd=str(root))[0] or {} view = lambda n: data[n] if isinstance(data.get(n), dict) else pr_view(root, n, "body,state,mergeCommit") # noqa: E731 読めない PR は根拠に書く(I4) @@ -1154,7 +1154,7 @@ def build_parser() -> argparse.ArgumentParser: p = sub.add_parser("changed-plugins", parents=[common], help="前のタグからの差分にある他のプラグインと上げる版") p.add_argument("--since", help="前のタグ(省略時は <--plugin>--v の接尾辞の無い最も新しいタグ)") p.add_argument("--plugin", help="除くプラグイン(既定は宣言の release.plugin)") - p.add_argument("--prs", nargs="+", type=int, metavar="PR番号", help="版に含む PR(本文と閉じる課題から上げ幅の候補を出す)") + p.add_argument("--prs", nargs="*", type=int, metavar="PR番号", help="版に含む PR(本文と閉じる課題から上げ幅の候補。値なしは範囲だけ)") p.add_argument("--approval", help="承認資料。「版を上げる他のプラグイン」の節を書く") p.add_argument("--set", action="append", metavar="名前=上げ幅", help="--approval: 承認ゲート 2 で決めた上げ幅へ行を書き直す") p.add_argument("--decided", help="承認資料の表の上げ幅で上げた版を返す(本番の bump-others)") @@ -1202,11 +1202,11 @@ def build_parser() -> argparse.ArgumentParser: p.add_argument("--version", required=True, type=version_arg) p.add_argument( "--prs", - nargs="+", + nargs="*", required=True, type=int, metavar="PR番号", - help="前のタグからベースブランチへ入った PR に足す PR(マージ前の PR など)", + help="前のタグからベースブランチへ入った PR に足す PR(マージ前の PR など。値なしなら範囲の PR だけ)", ) p.add_argument("--prev-tag") p.add_argument("--plugin", help="配るプラグイン(既定は宣言の release.plugin。タグ <名前>--v<版> に使う)") @@ -1218,7 +1218,7 @@ def build_parser() -> argparse.ArgumentParser: ) p.add_argument("--version", required=True, type=version_arg) p.add_argument( - "--prs", nargs="+", required=True, type=int, metavar="PR番号", help="--approval なら提示物の「含む PR」にあることを確かめるだけ" + "--prs", nargs="*", required=True, type=int, metavar="PR番号", help="--approval なら提示物の「含む PR」にあることを確かめるだけ" ) p.add_argument("--plugin", help="配るプラグイン(既定は宣言の release.plugin)") p.add_argument("--approval", help="本番承認の提示物。渡すと「配る中身」「検証への配布で確かめたこと」の欄を書く") diff --git a/plugins/ndf/scripts/tests/test_parallel_lane.py b/plugins/ndf/scripts/tests/test_parallel_lane.py index c940afe9d..70dacb679 100644 --- a/plugins/ndf/scripts/tests/test_parallel_lane.py +++ b/plugins/ndf/scripts/tests/test_parallel_lane.py @@ -111,6 +111,20 @@ def per_pr(*a, **k): assert calls == [[1, 2]] +def test_present_plan_starts_and_collects_range_only(tmp_path): + import supervise_lib # noqa: F401 + import parallel_release + from supervise_lib import limits + + d = tmp_path / "releases" / "1" / "1" + present, _ = parallel_release.plan_parallel_release(SCRIPTS.parents[2], "p1", d, "1.2.3") + by = {s["id"]: s for s in present["steps"]} + assert limits.run_error(present) is None # on_fail が無いステップを指さない + assert not any(by[i].get("on_fail") for i in ("facts", "explain", "others")) + assert by["facts"].get("gate_as_ok") is True # facts の 10 を関門で残さない + assert "--prs 0" not in json.dumps(present) and "--prs --approval" in by["facts"]["cmd"] + + def test_present_writes_to_present_plan_state_dir(tmp_path, monkeypatch): import supervise_lib # noqa: F401 import approved_commit diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index 031f647cf..b4afad89b 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -281,11 +281,34 @@ def test_char_request_outside_range_is_deferred_to_next_release(env, tmp_path): assert dfr == {"seq": 1, "reason": "範囲に入らないため次の配布へ回した", "at": None} ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release" and e.get("n") == 2 and e) assert ev["k"] == 1 and ev["represent"] is False - out = stop(main) - assert [(r["n"], r["state"]) for r in out["releases"]] == [(1, "released"), (2, "presented")] + wid = pl.workflow_id("p1") + durable.send_to(pl.KIND, pl.record_identity(main, "p1"), wid, {"kind": "stop"}, "inbox", f"{wid}:inbox:stop") + time.sleep(0.8) + assert durable.status(wid) not in ("SUCCESS",) # 提示のまま流れている配布の途中の stop も settle まで終わらない + resume(main, {"n": 2, "phase": "settle", "result": "rejected", "reason": "stop"}, "s2") + out = durable.output_of(wid) + assert [(r["n"], r["state"]) for r in out["releases"]] == [(1, "released"), (2, "rejected")] assert out["releases"][1]["requests"] == [r2] and out["pending"] == [r2] +def test_wait_returns_smaller_seq_first(env): + main, states = env["main"], env["states"] + states.write_text(json.dumps({"la": "running"})) + start(main) + first = until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "running" and e) + wid = pl.workflow_id("p1") + durable.send_to(pl.KIND, pl.record_identity(main, "p1"), wid, {"kind": "ask", "lane": "la", "q": "?"}, "inbox", f"{wid}:inbox:q1") + q = until(lambda: event(main, "p1", "questions")) + states.write_text(json.dumps({"la": "awaiting_approval"})) + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "notice") + ident = pl.record_identity(main, "p1") + got = durable.wait_from(pl.KIND, ident, wid, ["pause", "questions"], first["seq"], 5) + assert got.kind == "event" and got.value["key"] == "questions" and got.value["seq"] == q["seq"] + got = durable.wait_from(pl.KIND, ident, wid, ["pause", "questions"], q["seq"], 5) + assert got.kind == "event" and got.value["key"] == "pause" and got.value["action"] == "notice" + stop(main) + + def test_stop_during_release_waits_for_settle(env, tmp_path): main, states = env["main"], env["states"] states.write_text(json.dumps({"la": "running"})) From 305896c853206d6361e9e3665652c373dc2fa62b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 07:36:46 +0000 Subject: [PATCH 22/70] =?UTF-8?q?fix(parallel):=20=E6=9C=AC=E7=95=AA?= =?UTF-8?q?=E3=81=AE=E3=83=97=E3=83=A9=E3=83=B3=E3=81=AE=E3=80=8C=E8=B5=B7?= =?UTF-8?q?=E7=82=B9=E3=80=8D=E3=82=92=E3=83=99=E3=83=BC=E3=82=B9=E3=83=96?= =?UTF-8?q?=E3=83=A9=E3=83=B3=E3=83=81=E3=81=AE=E3=81=BE=E3=81=BE=E4=BF=9D?= =?UTF-8?q?=E3=81=A3=E3=81=A6=E6=89=BF=E8=AA=8D=E3=81=97=E3=81=9F=E3=82=B3?= =?UTF-8?q?=E3=83=9F=E3=83=83=E3=83=88=E3=82=92=E3=80=8C=E8=B5=B7=E7=82=B9?= =?UTF-8?q?=E3=81=AE=E3=82=B3=E3=83=9F=E3=83=83=E3=83=88=E3=80=8D=E3=81=A7?= =?UTF-8?q?=E6=B8=A1=E3=81=97=E3=80=81=E6=8F=90=E7=A4=BA=E3=81=AE=E7=AD=94?= =?UTF-8?q?=E3=81=88=E3=81=AE=E8=A8=98=E9=8C=B2=E3=81=AB=E6=89=BF=E8=AA=8D?= =?UTF-8?q?=E6=B8=88=E3=81=BF=E3=81=AE=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7?= =?UTF-8?q?=E3=82=AF=E3=83=88=20MVV=20=E3=81=AE=E5=8F=82=E7=85=A7=E3=82=92?= =?UTF-8?q?=E5=85=A5=E3=82=8C=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 本番のプランは「起点」を origin/<ベースブランチ> のまま残し、承認したコミットを新しい項目「起点のコミット」へ入れる。作業ツリー(_resolve_base)だけがこの項目を先に読み、版の判定と probe の {base} は今どおり「起点」を読む(設計 issue-2436-design-release-rule.md の「起点のコミットと版の判定」) - 提示の答えの記録(answer.json)を、承認済みならプロジェクト MVV の版と sha256 を持つ形で作る。MVV 判定が参照の無い状態を必ず断っていた - parallel.py の契約に、project_mvv の読み込みで辿れるようになった環境変数を足す PR 2452 のレビュー round 4(codex の総評の指摘 2 件) Co-Authored-By: Claude Opus 5.5 --- .../plugins__ndf__scripts__parallel.py.json | 13 ++++++++++ plugins/ndf/contracts/index.md | 26 +++++++++---------- plugins/ndf/scripts/lib/parallel_release.py | 19 +++++++++++--- plugins/ndf/scripts/supervise_lib/paths.py | 7 +++-- plugins/ndf/scripts/supervise_lib/plan.py | 3 ++- .../ndf/scripts/tests/test_parallel_lane.py | 23 ++++++++++++++++ 6 files changed, 72 insertions(+), 19 deletions(-) diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json index f6e58d92e..fbbef4a3b 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json @@ -98,11 +98,19 @@ } }, "env": [ + "CLAUDE_CONFIG_DIR", + "CODEX_HOME", "GH_TOKEN", "GITHUB_TOKEN", + "MONITOR_NO_EARLY_ERROR", + "NDF_ACCOUNTS_DIR", + "NDF_ACCOUNT_TOKEN_URL", + "NDF_ACCOUNT_USAGE_URL", + "NDF_CLAUDE_ACCOUNT", "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", "NDF_ESTIMATE_DIR", + "NDF_MVV_STATE_DIR", "NDF_PARALLEL_MAX_PLANS", "NDF_PARALLEL_POLL", "NDF_PARALLEL_RELAY", @@ -110,6 +118,11 @@ "NDF_RELAY_DIR", "NDF_RELAY_MAX_NAMED", "NDF_RELAY_NAME", + "NDF_SHARED_CONFIG_DIR", + "NDF_SUPERVISE_CLAUDE", + "NDF_SUPERVISE_CLAUDE_FALLBACK", + "NDF_SUPERVISE_LIMIT_SLEEP", + "NDF_SUPERVISE_MODEL", "NDF_SV_STATE_DIR", "NDF_WHOLE_RUNS_DIR", "XDG_STATE_HOME" diff --git a/plugins/ndf/contracts/index.md b/plugins/ndf/contracts/index.md index 76e32cb41..97e04b959 100644 --- a/plugins/ndf/contracts/index.md +++ b/plugins/ndf/contracts/index.md @@ -74,10 +74,10 @@ | `CLAUDE_CODE_ENTRYPOINT` | runtime | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/wait-notify.py` | | `CLAUDE_CODE_SESSION_ID` | runtime | `plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | | `CLAUDE_CODE_USE_BEDROCK` | runtime | `plugins/ndf/scripts/statusline.sh` | -| `CLAUDE_CONFIG_DIR` | runtime | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/statusline.sh`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/scripts/usage-report.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | +| `CLAUDE_CONFIG_DIR` | runtime | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/statusline.sh`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/scripts/usage-report.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | | `CLAUDE_PLUGIN_DATA` | runtime | `plugins/ndf/scripts/hook.py` | | `CLAUDE_PLUGIN_ROOT` | runtime | `plugins/ndf/scripts/resolve.sh`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | -| `CODEX_HOME` | runtime | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/usage-report.py`
`plugins/ndf/scripts/usage-subagent.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | +| `CODEX_HOME` | runtime | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/usage-report.py`
`plugins/ndf/scripts/usage-subagent.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | | `CRITIQUE_RETRY_AGENTS` | runtime | `plugins/ndf/skills/cross-review/scripts/critique-round.sh` | | `CROSS_REFACTORING_TMP_DIR` | runtime | `plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/launch-cli.sh`
`plugins/ndf/skills/cross-refactoring/scripts/prepare-worktrees.sh`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py` | | `CROSS_REVIEW_STATE` | runtime | `plugins/ndf/skills/fix/scripts/fix-steps.py` | @@ -90,11 +90,11 @@ | `HOME` | runtime | `plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/install-official-skills.sh`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/resolve.sh`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/statusline-switch.sh`
`plugins/ndf/scripts/statusline.sh`
`plugins/ndf/scripts/token-guard.sh`
`plugins/ndf/scripts/worktree-guard.sh`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | | `KIRO_SESSION_ID` | runtime | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/wait-notify.py` | | `LANG` | runtime | `plugins/ndf/scripts/release-verification-steps.py` | -| `MONITOR_NO_EARLY_ERROR` | runtime | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/project-decl.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | -| `NDF_ACCOUNTS_DIR` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | -| `NDF_ACCOUNT_TOKEN_URL` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | -| `NDF_ACCOUNT_USAGE_URL` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | -| `NDF_CLAUDE_ACCOUNT` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | +| `MONITOR_NO_EARLY_ERROR` | runtime | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/project-decl.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | +| `NDF_ACCOUNTS_DIR` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | +| `NDF_ACCOUNT_TOKEN_URL` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | +| `NDF_ACCOUNT_USAGE_URL` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | +| `NDF_CLAUDE_ACCOUNT` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | | `NDF_CODEX_SLACK_NOTIFY` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/wait-notify.py` | | `NDF_CONTEXT_DIR` | user | `plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/statusline.sh`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | | `NDF_CONTEXT_LIMIT` | user | `plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | @@ -111,7 +111,7 @@ | `NDF_LOCK_DIR` | user | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | | `NDF_LOOP_EXCLUDE` | runtime | `plugins/ndf/scripts/sprint-state.py` | | `NDF_MVV_CLAUDE` | user | `plugins/ndf/scripts/mvv-gate.py` | -| `NDF_MVV_STATE_DIR` | user | `plugins/ndf/scripts/ask.py`
`plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | +| `NDF_MVV_STATE_DIR` | user | `plugins/ndf/scripts/ask.py`
`plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | | `NDF_PARALLEL_MAX_PLANS` | user | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | | `NDF_PARALLEL_POLL` | user | `plugins/ndf/scripts/parallel.py` | | `NDF_PARALLEL_RELAY` | user | `plugins/ndf/scripts/parallel.py` | @@ -127,7 +127,7 @@ | `NDF_RELAY_SPAWNED` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | | `NDF_RELAY_TMUX` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | | `NDF_RELAY_UV` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | -| `NDF_SHARED_CONFIG_DIR` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | +| `NDF_SHARED_CONFIG_DIR` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | | `NDF_SKILL_REPO` | user | `plugins/ndf/scripts/issue-file.py` | | `NDF_SKIP_AUTH_CHECK` | user | `plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py` | | `NDF_SLACK_API_BASE` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/wait-notify.py` | @@ -135,10 +135,10 @@ | `NDF_SLEEP_MAX_SEC` | user | `plugins/ndf/scripts/hook.py` | | `NDF_STATUSLINE_AUTH_CACHE` | user | `plugins/ndf/scripts/statusline.sh` | | `NDF_STEP_EXTEND_FILE` | user | `plugins/ndf/scripts/supervise.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py` | -| `NDF_SUPERVISE_CLAUDE` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | -| `NDF_SUPERVISE_CLAUDE_FALLBACK` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | -| `NDF_SUPERVISE_LIMIT_SLEEP` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | -| `NDF_SUPERVISE_MODEL` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | +| `NDF_SUPERVISE_CLAUDE` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | +| `NDF_SUPERVISE_CLAUDE_FALLBACK` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | +| `NDF_SUPERVISE_LIMIT_SLEEP` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | +| `NDF_SUPERVISE_MODEL` | user | `plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | | `NDF_SUPERVISOR_CUT_RATIO` | user | `plugins/ndf/scripts/hook.py` | | `NDF_SV_STATE_DIR` | user | `plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | | `NDF_UPKEEP_NO_SLEEP` | user | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | diff --git a/plugins/ndf/scripts/lib/parallel_release.py b/plugins/ndf/scripts/lib/parallel_release.py index 8f77b05ba..999598ffd 100644 --- a/plugins/ndf/scripts/lib/parallel_release.py +++ b/plugins/ndf/scripts/lib/parallel_release.py @@ -164,7 +164,7 @@ def pipeline_cmd(supervise: str, d: Path) -> str: def build_plans(main: Path, name: str, seq: int, k: int, version: str, builder: Optional[Callable] = None) -> Path: - """`releases/<連番>/<提示>/` に提示のプランと本番のプランと答えの記録(`{"gates": []}`)を書き、置き場を返す。 + """`releases/<連番>/<提示>/` に提示のプランと本番のプランと答えの記録(`answer_state`)を書き、置き場を返す。 `builder(d, version)` が (提示のプラン, 本番のプラン) を返す。省けば `supervise_lib.parallel_plans` を使う。""" d = release_dir(main, name, seq, k) @@ -175,10 +175,22 @@ def build_plans(main: Path, name: str, seq: int, k: int, version: str, builder: (d / "present.json").write_text(json.dumps(present_plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") (d / "release.json").write_text(json.dumps(release_plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") if not (d / "answer.json").exists(): - (d / "answer.json").write_text(json.dumps({"gates": []}) + "\n", encoding="utf-8") + (d / "answer.json").write_text(json.dumps(answer_state(main)) + "\n", encoding="utf-8") return d +def answer_state(main: Path) -> dict: + """答えの記録の初期値(スプリントの状態ファイルの形)。MVV 判定は承認済みのプロジェクト MVV の参照を状態から読むため、 + 承認済みなら `sprint-state.py init` と同じ `project_mvv`(版と sha256)を足す。無ければ判定が人の承認へ戻す。""" + import project_mvv + + state: dict = {"gates": []} + mvv = project_mvv.load_mvv(main) + if mvv.approved: + state["project_mvv"] = {"version": mvv.version, "sha256": mvv.sha256} + return state + + # ---- プランの組み立て(supervise_lib の部品を使う。supervise_lib は呼ぶときに読む) ---- @@ -354,6 +366,7 @@ def plan_parallel_release(main, name: str, d, version: str) -> tuple[dict, dict] approval_path=approval, ) ) - release_plan["起点"] = "{queue_approved:present}" + # 「起点」は origin/<ベースブランチ> のまま(版の判定が読む)。承認したコミットは作業ツリーだけが読む項目へ入れる + release_plan["起点のコミット"] = "{queue_approved:present}" release_plan["並列の計画"] = name return present_plan, release_plan diff --git a/plugins/ndf/scripts/supervise_lib/paths.py b/plugins/ndf/scripts/supervise_lib/paths.py index bc62b4bed..1dc4b9efe 100644 --- a/plugins/ndf/scripts/supervise_lib/paths.py +++ b/plugins/ndf/scripts/supervise_lib/paths.py @@ -74,8 +74,11 @@ def _prepare_deps(wt, **kw) -> str | None: def _resolve_base(plan: dict, repo: str) -> tuple[str | None, str | None]: - """作業ツリーの起点(起点 → base_branch → 宣言の順)を決め、origin なら fetch する。(起点, 誤りの文) を返す。""" - base = plan.get("起点") + """作業ツリーの起点(起点のコミット → 起点 → base_branch → 宣言の順)を決め、origin なら fetch する。(起点, 誤りの文) を返す。 + + 「起点のコミット」は作業ツリーだけが読む(まとめた配布の本番のプランが承認したコミットを入れる)。版の判定と + probe の {base} は「起点」(`origin/<ベースブランチ>`)を読むため、承認したコミットを「起点」へ入れない。""" + base = plan.get("起点のコミット") or plan.get("起点") if not base: b = plan.get("base_branch") or decl.declared_base_of([repo]) if not b: diff --git a/plugins/ndf/scripts/supervise_lib/plan.py b/plugins/ndf/scripts/supervise_lib/plan.py index e1003dd4d..54e0dcda1 100644 --- a/plugins/ndf/scripts/supervise_lib/plan.py +++ b/plugins/ndf/scripts/supervise_lib/plan.py @@ -5,7 +5,8 @@ "フェーズ": "検査", "課題": [818], "モード": "standard", "作業場所": "/abs/worktree", "branch": "feat/issue-818-x", # 省略可。作業場所が無ければ起動時に作業ツリーを作る - "起点": "origin/main", # branch から作るときの起点(既定 origin/) + "起点": "origin/main", # branch から作るときの起点(既定 origin/)。版の判定もこのブランチを読む + "起点のコミット": "", # 省略可。作業ツリーだけをこのコミットから作る(起点より先に読む) "base_branch": "main", # 起点のブランチ。pr のステップの宛先と preset の {base}(既定は .ndf/worktree.json) "no_reports": "-p no:x", # run のステップの PYTEST_ADDOPTS に足す(既定は .ndf/supervise.json の test.no_reports) "リポジトリ": "/abs/repo", # 作業ツリーの元(省略時は作業場所の /.worktrees/ より前) diff --git a/plugins/ndf/scripts/tests/test_parallel_lane.py b/plugins/ndf/scripts/tests/test_parallel_lane.py index 70dacb679..acce394f6 100644 --- a/plugins/ndf/scripts/tests/test_parallel_lane.py +++ b/plugins/ndf/scripts/tests/test_parallel_lane.py @@ -125,6 +125,29 @@ def test_present_plan_starts_and_collects_range_only(tmp_path): assert "--prs 0" not in json.dumps(present) and "--prs --approval" in by["facts"]["cmd"] +def test_release_plan_keeps_base_branch_and_takes_approved_commit_for_worktree(tmp_path): + import supervise_lib # noqa: F401 + import parallel_release + from supervise_lib import paths + + d = tmp_path / "releases" / "1" / "1" + _, release = parallel_release.plan_parallel_release(SCRIPTS.parents[2], "p1", d, "1.2.3") + assert release["起点"].startswith("origin/") and release["起点のコミット"] == "{queue_approved:present}" + plan = {**release, "起点のコミット": "abc123"} + assert paths._resolve_base(plan, str(tmp_path)) == ("abc123", None) # 作業ツリーは承認したコミットから + + +def test_answer_state_carries_approved_project_mvv(tmp_path, monkeypatch): + import parallel_release + import project_mvv + + approved = project_mvv.ProjectMvv(status="approved", version=4, sha256="s") + monkeypatch.setattr(project_mvv, "load_mvv", lambda root: approved) + assert parallel_release.answer_state(tmp_path) == {"gates": [], "project_mvv": {"version": 4, "sha256": "s"}} + monkeypatch.setattr(project_mvv, "load_mvv", lambda root: project_mvv.ProjectMvv(status="none")) + assert parallel_release.answer_state(tmp_path) == {"gates": []} + + def test_present_writes_to_present_plan_state_dir(tmp_path, monkeypatch): import supervise_lib # noqa: F401 import approved_commit From c2aa4a8f6ddf83b547dd64c451c5fac922382edd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 07:45:54 +0000 Subject: [PATCH 23/70] =?UTF-8?q?feat(release):=20main=20=E5=AE=9B?= =?UTF-8?q?=E3=81=AE=20Pull=20Request=20=E3=81=AB=20develop=20=E3=81=AE?= =?UTF-8?q?=E7=A5=96=E5=85=88=E3=81=AE=20release/v*=20=E3=82=92=E9=80=9A?= =?UTF-8?q?=E3=81=99=EF=BC=88#2436=20=E3=81=AE=20R1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-pr-base.sh が head の SHA を受け、head が release/v* なら先頭が origin/<起点> の祖先のときだけ通す。 pr-base-guard は履歴をすべて取り、head の SHA を渡す。 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr-base-guard.yml | 6 +++- scripts/check-pr-base.sh | 22 +++++++++++-- scripts/tests/test_pr_base_guard.py | 51 +++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-base-guard.yml b/.github/workflows/pr-base-guard.yml index 71c0cda66..554f6c557 100644 --- a/.github/workflows/pr-base-guard.yml +++ b/.github/workflows/pr-base-guard.yml @@ -11,6 +11,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + # release/v* の head が起点ブランチの祖先かを git merge-base で判定するため、履歴をすべて取る + fetch-depth: 0 - name: Check the branch this pull request came from # 宛先が既定ブランチでなければ、チェックは行わずに成功で終わる。既定ブランチの名前は # ワークフローへ書かず、リポジトリの設定から受け取る @@ -18,4 +21,5 @@ jobs: # ブランチ名をそのままシェルの本文へ埋めない。名前に置換の記法を含められる。 env: HEAD_REF: ${{ github.head_ref }} - run: bash scripts/check-pr-base.sh "$HEAD_REF" + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: bash scripts/check-pr-base.sh "$HEAD_REF" "$HEAD_SHA" diff --git a/scripts/check-pr-base.sh b/scripts/check-pr-base.sh index 99fabf010..244a01c39 100755 --- a/scripts/check-pr-base.sh +++ b/scripts/check-pr-base.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bash -# 既定ブランチ宛の Pull Request が、開発の起点ブランチから出ていることを確かめる。 +# 既定ブランチ宛の Pull Request が、開発の起点ブランチに含まれるコミットだけを出すことを確かめる。 +# +# 通すのは head が起点ブランチのときと、head が `release/v*` で先頭のコミットが `origin/<起点>` の +# 祖先のとき(承認したコミットに版上げを積み、先に起点へ取り込んだ本番の配布のブランチ)である。 # # 配布のチャネルを分けるリポジトリでは、正式版のブランチへ直に Pull Request を出さない。 # 既定ブランチは正式版のままにするため、`gh pr create` の宛先の既定も正式版のままになる。 @@ -9,12 +12,13 @@ # 作る前は成功で通す。有効にする時期を人の手で合わせずに済ませるためである。 set -uo pipefail -if [ "$#" -ne 1 ]; then - echo "使い方: check-pr-base.sh " >&2 +if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then + echo "使い方: check-pr-base.sh [<分岐元の先頭の SHA>]" >&2 exit 2 fi head_ref="$1" +head_sha="${2:-}" dev_base=$(jq -r 'select(.version == 1) | .base_branch | select(type == "string")' \ .ndf/worktree.json 2>/dev/null) @@ -37,5 +41,17 @@ done <<<"$listing" [ "$head_ref" = "$dev_base" ] && exit 0 +case "$head_ref" in +release/v*) + if [ -n "$head_sha" ] && + GIT_TERMINAL_PROMPT=0 git fetch -q origin "refs/heads/$dev_base:refs/remotes/origin/$dev_base" 2>/dev/null && + git merge-base --is-ancestor "$head_sha" "refs/remotes/origin/$dev_base" 2>/dev/null; then + exit 0 + fi + echo "::error::${head_ref} の先頭(${head_sha:-SHA の指定なし})が origin/${dev_base} に含まれていません。${dev_base} へ取り込んでから既定ブランチへ出してください" + exit 1 + ;; +esac + echo "::error::既定ブランチ宛の Pull Request は ${dev_base} からのみです。--base ${dev_base} を付けてください(分岐元: ${head_ref})" exit 1 diff --git a/scripts/tests/test_pr_base_guard.py b/scripts/tests/test_pr_base_guard.py index 41474ec10..f6cd2b44c 100644 --- a/scripts/tests/test_pr_base_guard.py +++ b/scripts/tests/test_pr_base_guard.py @@ -117,3 +117,54 @@ def test_local_declaration_alone_does_not_enable_the_guard(origin_repo: Path) -> push_branch(origin_repo, "main2") assert guard(origin_repo, "feature/x").returncode == 0 + + +# --- release/v<版> を head にした本番の Pull Request(#2436 の R1) --------------- + + +def guard_sha(repo: Path, head_ref: str, sha: str) -> subprocess.CompletedProcess: + return subprocess.run(["bash", str(GUARD), head_ref, sha], cwd=str(repo), capture_output=True, text=True) + + +def commit_on(repo: Path, name: str) -> str: + (repo / name).write_text(name, encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", name], check=True) + subprocess.run(["git", "-C", str(repo), "commit", "-q", "-m", name], check=True) + return subprocess.run(["git", "-C", str(repo), "rev-parse", "HEAD"], capture_output=True, text=True, check=True).stdout.strip() + + +def release_world(repo: Path) -> tuple[str, str]: + """origin の develop の祖先にある版上げのコミットと、develop に無いコミットを返す。""" + declare(repo, {"version": 1, "base_branch": "develop"}) + bumped = commit_on(repo, "bump.txt") + after = commit_on(repo, "later.txt") + subprocess.run(["git", "-C", str(repo), "push", "-q", "origin", f"{after}:refs/heads/develop"], check=True) + subprocess.run(["git", "-C", str(repo), "checkout", "-q", "-b", "side", bumped], check=True) + stray = commit_on(repo, "stray.txt") + subprocess.run(["git", "-C", str(repo), "push", "-q", "origin", f"{stray}:refs/heads/release/v10.28.0"], check=True) + return bumped, stray + + +def test_release_branch_inside_the_base_passes(origin_repo: Path) -> None: + """先頭が origin/develop の祖先の release/v* は通す(develop が先へ進んでいても)。""" + bumped, _ = release_world(origin_repo) + assert guard_sha(origin_repo, "release/v10.28.0", bumped).returncode == 0 + + +def test_release_branch_outside_the_base_fails(origin_repo: Path) -> None: + """develop に含まれないコミットを持つ release/v* は落とす。""" + _, stray = release_world(origin_repo) + got = guard_sha(origin_repo, "release/v10.28.0", stray) + assert got.returncode == 1 and "develop" in got.stdout + got.stderr + + +def test_release_branch_without_the_head_sha_fails(origin_repo: Path) -> None: + """head の SHA を渡さない release/v* は、名前だけでは通さない。""" + release_world(origin_repo) + assert guard(origin_repo, "release/v10.28.0").returncode == 1 + + +def test_other_branch_inside_the_base_fails(origin_repo: Path) -> None: + """develop に含まれていても、release/v* でも develop でもない head は落とす。""" + bumped, _ = release_world(origin_repo) + assert guard_sha(origin_repo, "feature/x", bumped).returncode == 1 From abce344a8e3115c36ed0abd5254e36d67468607e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 07:47:14 +0000 Subject: [PATCH 24/70] =?UTF-8?q?feat(release):=20release/v<=E7=89=88>=20?= =?UTF-8?q?=E3=81=AE=E5=8F=96=E3=82=8A=E8=BE=BC=E3=81=BF=E3=81=A7=E7=89=88?= =?UTF-8?q?=E6=95=B0=E3=81=A0=E3=81=91=E3=81=AE=E8=A1=9D=E7=AA=81=E3=82=92?= =?UTF-8?q?=E6=9C=AC=E7=95=AA=E3=81=AE=E7=89=88=E3=81=A7=E8=A7=A3=E3=81=8F?= =?UTF-8?q?=EF=BC=88#2436=20=E3=81=AE=20R5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release_lib/version_merge.py が作業ツリーに触れずにマージのコミットを作り、衝突した塊が版数の違いだけなら release/v<版> の側を取る。版数の外が衝突すれば解かずに止める。 Co-Authored-By: Claude Opus 5.5 --- .../ndf/scripts/release_lib/version_merge.py | 111 ++++++++++++++++++ .../tests/test_release_version_merge.py | 98 ++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 plugins/ndf/scripts/release_lib/version_merge.py create mode 100644 plugins/ndf/scripts/tests/test_release_version_merge.py diff --git a/plugins/ndf/scripts/release_lib/version_merge.py b/plugins/ndf/scripts/release_lib/version_merge.py new file mode 100644 index 000000000..f5ca95b10 --- /dev/null +++ b/plugins/ndf/scripts/release_lib/version_merge.py @@ -0,0 +1,111 @@ +"""release/v<版> を起点のブランチへ取り込むときの版数の衝突を解く(#2436 の R5。`release-steps.py` の release から分けた)。 + +承認の後に他の配布が起点のブランチの版数を上げていると、release/v<版> の取り込みが版数を持つ箇所で衝突する。 +release/v<版> そのものへは起点のブランチを取り込まず(取り込むと承認の外のコミットが本番チャネルへ出る)、 +release/v<版> の先頭と起点のブランチの先頭を親に持つマージのコミットを作業ツリーに触れずに作る。衝突した塊が +版数の違いだけなら release/v<版> の側(本番の版)を取り、1 つでも版数の外が違えば解かずに止める。 +""" + +from __future__ import annotations + +import os +import re +import tempfile + +from step_result import StepError, git, run + +VERSION_RE = re.compile(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?") +_OURS, _SEP, _THEIRS = "<<<<<<< ", "=======", ">>>>>>> " + + +class VersionConflict(StepError): + """版数の外の箇所が衝突した(解かずに止める。終了コード 1)。paths は解けなかったパス。""" + + def __init__(self, paths: list[str]): + super().__init__(f"取り込みで版数の外の箇所が衝突した(解かずに止めた): {', '.join(paths)}", 1) + self.paths = paths + + +def _same_but_versions(a: list[str], b: list[str]) -> bool: + return [VERSION_RE.sub("", line) for line in a] == [VERSION_RE.sub("", line) for line in b] + + +def resolve_text(text: str) -> str | None: + """衝突の印を持つ本文の塊がすべて版数の違いだけなら、こちら(release/v<版>)の側を取った本文。違えば None。""" + out: list[str] = [] + ours: list[str] | None = None + theirs: list[str] | None = None + for line in text.splitlines(keepends=True): + bare = line.rstrip("\r\n") + if ours is None: + if bare.startswith(_OURS): + ours = [] + else: + out.append(line) + elif theirs is None: + if bare == _SEP: + theirs = [] + elif bare.startswith("||||||| "): + return None # diff3 の形は扱わない(merge.conflictStyle を merge にして作る) + else: + ours.append(line) + elif bare.startswith(_THEIRS): + if not _same_but_versions(ours, theirs): + return None + out.extend(ours) + ours = theirs = None + else: + theirs.append(line) + return "".join(out) if ours is None else None + + +def _merge_tree(root, ours: str, theirs: str) -> tuple[str, dict[str, dict[int, tuple[str, str]]]]: + """(結果の木, {パス: {段: (mode, oid)}})。衝突が無ければ段の一覧は空。""" + p = git(root, "-c", "merge.conflictStyle=merge", "merge-tree", "--write-tree", "-z", "--no-messages", ours, theirs, check=False) + if p.returncode not in (0, 1): + raise StepError(f"git merge-tree が終了コード {p.returncode}: {p.stderr.strip()[:300]}") + parts = [x for x in p.stdout.split("\0") if x] + stages: dict[str, dict[int, tuple[str, str]]] = {} + for entry in parts[1:]: + meta, _, path = entry.partition("\t") + mode, oid, stage = meta.split(" ") + stages.setdefault(path, {})[int(stage)] = (mode, oid) + return parts[0], stages + + +def sync_commit(root, ours: str, theirs: str, message: str) -> tuple[str, list[str]]: + """ours(release/v<版> の先頭)に theirs(起点のブランチの先頭)を取り込んだマージのコミットを作り、 + (コミット, 版数で解いたパス) を返す。作業ツリーと index には触れない。版数の外が衝突すれば VersionConflict。""" + tree, stages = _merge_tree(root, ours, theirs) + resolved: dict[str, tuple[str, str]] = {} + bad: list[str] = [] + for path, by in sorted(stages.items()): + text = git(root, "cat-file", "blob", f"{tree}:{path}", check=False) if 2 in by and 3 in by else None + fixed = resolve_text(text.stdout) if text is not None and text.returncode == 0 else None + if fixed is None: + bad.append(path) + continue + oid = run(["git", "-C", str(root), "hash-object", "-w", "--stdin"], input=fixed).stdout.strip() + resolved[path] = (by[2][0], oid) + if bad: + raise VersionConflict(bad) + if resolved: + tree = _rewrite_tree(root, tree, resolved) + sha = git(root, "commit-tree", tree, "-p", ours, "-p", theirs, "-m", message).stdout.strip() + return sha, sorted(resolved) + + +def _rewrite_tree(root, tree: str, blobs: dict[str, tuple[str, str]]) -> str: + """tree のパスを blobs の (mode, oid) へ置き換えた木。作業場所の index は使わない。""" + fd, index = tempfile.mkstemp(prefix="ndf-version-merge-") + os.close(fd) + os.unlink(index) # 空のファイルは壊れた index とみなされるため、無い状態から read-tree で作らせる + env = {**os.environ, "GIT_INDEX_FILE": index} + try: + run(["git", "-C", str(root), "read-tree", tree], env=env) + for path, (mode, oid) in blobs.items(): + run(["git", "-C", str(root), "update-index", "--cacheinfo", f"{mode},{oid},{path}"], env=env) + return run(["git", "-C", str(root), "write-tree"], env=env).stdout.strip() + finally: + if os.path.exists(index): + os.unlink(index) diff --git a/plugins/ndf/scripts/tests/test_release_version_merge.py b/plugins/ndf/scripts/tests/test_release_version_merge.py new file mode 100644 index 000000000..7fef3d34c --- /dev/null +++ b/plugins/ndf/scripts/tests/test_release_version_merge.py @@ -0,0 +1,98 @@ +"""release_lib/version_merge.py: release/v<版> を develop へ取り込むときの版数の衝突(#2436 の R5)。 + +一時の git リポジトリで、develop が開発版の版数へ上がった後に本番の版の release/v<版> を取り込む。 +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib")) + +from release_lib import version_merge # noqa: E402 + +MANIFEST = "plugins/ndf/.claude-plugin/plugin.json" + + +def git(root: Path, *args: str) -> str: + return subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, check=True).stdout.strip() + + +def write(root: Path, rel: str, text: str) -> None: + (root / rel).parent.mkdir(parents=True, exist_ok=True) + (root / rel).write_text(text) + + +def manifest(version: str) -> str: + return json.dumps({"name": "ndf", "version": version, "description": f"NDF (v{version})", "author": "a"}, indent=2) + "\n" + + +def commit(root: Path, files: dict, msg: str) -> str: + for rel, text in files.items(): + write(root, rel, text) + git(root, "add", "-A") + git(root, "commit", "-q", "-m", msg) + return git(root, "rev-parse", "HEAD") + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + root = tmp_path / "r" + root.mkdir() + git(root, "init", "-q", "-b", "develop") + git(root, "config", "user.email", "t@example.com") + git(root, "config", "user.name", "t") + commit(root, {MANIFEST: manifest("10.27.14"), "src/a.py": "a = 1\n"}, "init") + return root + + +def branch_release(root: Path, files: dict) -> str: + """承認したコミット(今の develop)から release/v10.28.0 を切り、版上げを積む。""" + git(root, "checkout", "-q", "-b", "release/v10.28.0") + tip = commit(root, {MANIFEST: manifest("10.28.0"), **files}, "bump 10.28.0") + git(root, "checkout", "-q", "develop") + return tip + + +def test_hunks_that_differ_only_in_versions_take_the_release_side(): + text = "x\n<<<<<<< ours\n \"version\": \"10.28.0\",\n=======\n \"version\": \"10.28.0-dev.4\",\n>>>>>>> theirs\ny\n" + assert version_merge.resolve_text(text) == 'x\n "version": "10.28.0",\ny\n' + + +def test_hunks_that_differ_beyond_versions_are_not_resolved(): + text = "<<<<<<< ours\nv 10.28.0 a\n=======\nv 10.28.0-dev.4 b\n>>>>>>> theirs\n" + assert version_merge.resolve_text(text) is None + + +def test_sync_takes_the_release_version_and_keeps_the_release_tip_as_a_parent(repo): + """develop の版数が 10.28.0-dev.4 に上がっていても、本番の版を取り、release/v<版> の先頭を親に持つ。""" + tip = branch_release(repo, {}) + dev = commit(repo, {MANIFEST: manifest("10.28.0-dev.4"), "src/b.py": "b = 2\n"}, "dev 10.28.0-dev.4") + sha, resolved = version_merge.sync_commit(repo, tip, dev, "sync") + assert resolved == [MANIFEST] + assert git(repo, "rev-parse", f"{sha}^1", f"{sha}^2").split() == [tip, dev] + assert json.loads(git(repo, "show", f"{sha}:{MANIFEST}"))["version"] == "10.28.0" + assert git(repo, "show", f"{sha}:src/b.py") == "b = 2" + assert git(repo, "rev-parse", "release/v10.28.0") == tip # release/v<版> そのものへは develop を取り込まない + + +def test_sync_stops_when_a_place_without_versions_conflicts(repo): + """版数を持たない箇所の衝突は解かずに止める(R5)。""" + tip = branch_release(repo, {"src/a.py": "a = 3\n"}) + dev = commit(repo, {MANIFEST: manifest("10.28.0-dev.4"), "src/a.py": "a = 4\n"}, "dev") + with pytest.raises(version_merge.VersionConflict) as e: + version_merge.sync_commit(repo, tip, dev, "sync") + assert e.value.code == 1 and e.value.paths == ["src/a.py"] + + +def test_sync_without_conflicts_is_a_plain_merge(repo): + tip = branch_release(repo, {}) + dev = commit(repo, {"src/b.py": "b = 2\n"}, "dev") + sha, resolved = version_merge.sync_commit(repo, tip, dev, "sync") + assert resolved == [] and git(repo, "rev-parse", f"{sha}^1", f"{sha}^2").split() == [tip, dev] From f7a0745dbfa0786c218c1a3b63d4ecc84e8c7357 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 07:51:55 +0000 Subject: [PATCH 25/70] =?UTF-8?q?feat(loop):=20=E4=B8=A6=E5=88=97=E3=81=AE?= =?UTF-8?q?=E8=A8=88=E7=94=BB=E3=81=AE=E3=83=AC=E3=83=BC=E3=83=B3=E3=81=AE?= =?UTF-8?q?=E5=91=A8=E5=9B=9E=E3=82=92=E9=85=8D=E5=B8=83=E5=BE=85=E3=81=A1?= =?UTF-8?q?=E3=83=BB=E8=B3=AA=E5=95=8F=E5=BE=85=E3=81=A1=E3=81=A7=E7=B7=A0?= =?UTF-8?q?=E3=82=81=E3=80=81=E7=AD=94=E3=81=88=E3=82=92=E5=91=A8=E5=9B=9E?= =?UTF-8?q?=E3=81=AE=E5=A2=83=E3=81=A7=E8=AA=AD=E3=82=93=E3=81=A7=E6=AE=8B?= =?UTF-8?q?=E3=82=8A=E3=82=92=E6=B5=81=E3=81=99=EF=BC=88#2436=20=E3=81=AE?= =?UTF-8?q?=206a=E3=83=BB6d=E3=83=BB6e=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 配布の依頼のプラン(フェーズ「配布の依頼」)が承認ゲートで終わった周回を、承認ゲート 2 として止めずに配布待ちで締めて次の周回へ進む - 保留の周回の課題を選定から外し、周回の境で答えを古い順に読む(released はまとめの pipeline、rejected は人へ戻した、failed は失敗) - loop finish / loop pending / loop hold-question を足し、loop answer に --revise --note を足す - 答えの無い保留の周回が上限(loop.release_pending_max、既定 2)に達するか、待ちだけが残れば waiting_only で止まる - オーガナイザーへ送った設計の承認の答えを持ち主のレーンが境で処理する(ラベルと loop answer) Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/pace.py | 10 +- plugins/ndf/scripts/lib/parallel_lane.py | 16 ++ plugins/ndf/scripts/lib/sprint_loop.py | 148 ++++++++++--- plugins/ndf/scripts/lib/sprint_loop_cli.py | 18 +- plugins/ndf/scripts/lib/sprint_loop_gate.py | 48 ++++- plugins/ndf/scripts/lib/sprint_loop_gate2.py | 166 ++++++++++++++- plugins/ndf/scripts/lib/sprint_loop_record.py | 77 +++++-- plugins/ndf/scripts/lib/sprint_loop_select.py | 82 +++++-- plugins/ndf/scripts/tests/test_pace.py | 2 +- plugins/ndf/scripts/tests/test_sprint_loop.py | 201 ++++++++++++++++++ 10 files changed, 708 insertions(+), 60 deletions(-) diff --git a/plugins/ndf/scripts/lib/pace.py b/plugins/ndf/scripts/lib/pace.py index d431aab31..d7187c959 100644 --- a/plugins/ndf/scripts/lib/pace.py +++ b/plugins/ndf/scripts/lib/pace.py @@ -88,7 +88,13 @@ def read_pace(root) -> dict: return {**d, "fast": fast, "auto": auto, "areas": areas, "triggers": triggers, "boundary_paths": list(boundary_paths)} -LOOP_DEFAULTS = {"enabled": False, "budget": {"laps": 3, "usd": 60, "hours": 24}, "same_failure": 2, "max_issues": 3} # #2089 の決定 5・8 +LOOP_DEFAULTS = { + "enabled": False, + "budget": {"laps": 3, "usd": 60, "hours": 24}, + "same_failure": 2, + "max_issues": 3, + "release_pending_max": 2, # 配布待ちと質問待ちの周回を合わせた上限(#2436) +} # #2089 の決定 5・8 def read_loop(root) -> dict: @@ -105,7 +111,7 @@ def read_loop(root) -> dict: if set(budget) != set(LOOP_DEFAULTS["budget"]) or not all(_is_positive_number(v) for v in budget.values()): raise PaceError(f"進め方の宣言の loop.budget は laps・usd・hours を正の数で書く: {path}") out = {"enabled": sec.get("enabled") is True, "budget": budget} - for key in ("same_failure", "max_issues"): + for key in ("same_failure", "max_issues", "release_pending_max"): v = _default(sec, key, LOOP_DEFAULTS[key]) if isinstance(v, bool) or not isinstance(v, int) or v < 1: raise PaceError(f"進め方の宣言の loop.{key} は 1 以上の整数で書く: {path}") diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py index b1b6f2a6a..694bd5daa 100644 --- a/plugins/ndf/scripts/lib/parallel_lane.py +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -188,3 +188,19 @@ def write_request(state: Path, data: dict) -> Path: (Path(state) / "work").mkdir(parents=True, exist_ok=True) (Path(state) / "work" / "request.txt").write_text(json.dumps(data, ensure_ascii=False) + "\n", encoding="utf-8") return Path(state) / "work" / "request.txt" + + +def answers_of(main: Path, name: str, ids: list[str]) -> dict[str, dict]: + """依頼ごとの {"answer": `answer-<依頼>`, "defer": `defer-<依頼>`} を待たずに読む(周回の境で読む。#2436 の 6a・6e)。 + 耐久の記録が無ければ、どれも答えの無いものとして返す(DBOS を import しない)。""" + out = {i: {"answer": None, "defer": None} for i in ids} + if not ids or not record_exists(main, name): + return out + import durable + + with durable.open_client(KIND, record_identity(main, name)) as c: + for i in ids: + ans = c.get_event(workflow_id(name), f"answer-{i}", timeout_seconds=0) + dfr = c.get_event(workflow_id(name), f"defer-{i}", timeout_seconds=0) + out[i] = {"answer": ans if isinstance(ans, dict) else None, "defer": dfr if isinstance(dfr, dict) else None} + return out diff --git a/plugins/ndf/scripts/lib/sprint_loop.py b/plugins/ndf/scripts/lib/sprint_loop.py index d1e84a0e4..43c950ab2 100644 --- a/plugins/ndf/scripts/lib/sprint_loop.py +++ b/plugins/ndf/scripts/lib/sprint_loop.py @@ -31,6 +31,8 @@ DONE, FAILED, RETURNED, + QUESTION_PENDING, + PENDING, GATE_1, FINISHED, NOT_RUN, @@ -51,6 +53,7 @@ handoff_path, update_handoff, lap_rows, + pending_laps, selected_issues, ) @@ -76,7 +79,7 @@ owner_line, ) -from sprint_loop_gate2 import gate2_answer, gate2_pipeline +from sprint_loop_gate2 import finish_held, gate2_answer, gate2_pipeline, pending_pipeline # ---------------------------------------------------------------- start @@ -152,6 +155,7 @@ def start_new(env: Env, name: str, decl: dict, project, milestones: list[str], b "budget_history": [], "same_failure": decl["same_failure"], "max_issues": decl["max_issues"], + "release_pending_max": decl.get("release_pending_max", 2), "milestones": list(milestones or []), "goal": goal, "status": "running", @@ -325,6 +329,7 @@ def cmd_sprint(env: Env, name: str, mode: str, dev: str, prod: str | None, extra close = build_close(env, rec, lap, issues) except (LoopError, OSError, ValueError) as e: return refused_stop(env, rec, before, "supervise.py new close --pace auto", 1, str(e)) + lap["close_plans"] = close # 配布待ち・質問待ちで締めた周回は、答えの後にこのまとめを流す(#2436) manifest = Path(lap["out"]) / "sprint.json" index = json.loads(manifest.read_text(encoding="utf-8"))["ステージ"] lap["manifest"] = str(manifest) @@ -355,40 +360,79 @@ def cmd_sprint(env: Env, name: str, mode: str, dev: str, prod: str | None, extra # ---------------------------------------------------------------- answer / approve-mvv / stop -def cmd_answer(env: Env, name: str, number: int, approved: bool, pr: int | None) -> dict: +def cmd_answer( + env: Env, name: str, number: int, approved: bool, pr: int | None, revise: bool = False, note: str | None = None +) -> dict: with open_record(env, name) as (rec, before): w = next((w for w in reversed(rec.get("waiting", [])) if w["number"] == number and not w.get("answer")), None) if w is None: return refusal_order(f"#{number} は人の判断待ちでない") - w.update(answer="approved" if approved else "rejected", answered_at=env.now()) - if pr: - w["pr"] = pr - lap = rec["laps"][w["lap"] - 1] - state = lap.get("state_path") - notes = [] - if state: - cmd = [ - sys.executable, - str(SPRINT_STATE), - "gate", - state, - GATE_1, - "--by", - "user", - "--what", - f"開発ループ {name} の #{number} の設計", - ] - cmd += ["--outcome", w["answer"], "--root", str(env.main_dir())] + (["--pr", str(w["pr"])] if w.get("pr") else []) - code, so, se = env.run(cmd, cwd=str(env.main_dir())) - if code != 0: - notes.append(f"承認の記録を書けない({code}): {(se or so).strip()[:200]}") - if err := mark_issue(env, rec, w): - notes.append(err) + if revise: + try: + text = Path(note or "").read_text(encoding="utf-8").strip() + except OSError as e: + return refusal_order(f"指摘のファイルを読めない: {e}") + notes = apply_answer(env, rec, w, "revise" if revise else ("approved" if approved else "rejected"), pr, text if revise else None) write_record(env, rec, before) - after = "設計 PR のマージの後の周回で選定の対象に戻る" if approved else "選定の対象に戻さない(差し戻し)" + if revise: + after = "次の周回で先頭に選び、指摘を読んで同じ設計 Pull Request を直す" + else: + after = "設計 PR のマージの後の周回で選定の対象に戻る" if approved else "選定の対象に戻さない(差し戻し)" return loop_ok(f"#{number} の答え({w['answer']})を書いた。{after}", rec, [w, *({"note": n} for n in notes)], status="answered") +def apply_answer(env: Env, rec: dict, w: dict, answer: str, pr: int | None, note: str | None = None) -> list[str]: + """人の判断待ちの行へ答えを書き、スプリント状態の承認の記録と課題の本文の節を更新する。書けなかった理由を返す。 + 直して出し直す(revise)は答えの確定でないため承認の記録を書かず、指摘と回数を行と本文に書く(#2436 の 6e)。""" + w.update(answer=answer, answered_at=env.now()) + if pr: + w["pr"] = pr + notes: list[str] = [] + if answer == "revise": + w["note"] = note or "" + w["revise_count"] = sum(1 for x in rec.get("waiting", []) if x["number"] == w["number"] and x.get("answer") == "revise") + if err := mark_issue(env, rec, w): + notes.append(err) + return notes + lap = rec["laps"][w["lap"] - 1] + state = lap.get("state_path") + if state: + cmd = [ + sys.executable, + str(SPRINT_STATE), + "gate", + state, + GATE_1, + "--by", + "user", + "--what", + f"開発ループ {rec['name']} の #{w['number']} の設計", + ] + cmd += ["--outcome", w["answer"], "--root", str(env.main_dir())] + (["--pr", str(w["pr"])] if w.get("pr") else []) + code, so, se = env.run(cmd, cwd=str(env.main_dir())) + if code != 0: + notes.append(f"承認の記録を書けない({code}): {(se or so).strip()[:200]}") + if err := mark_issue(env, rec, w): + notes.append(err) + return notes + + +def apply_gate1_answers(env: Env, rec: dict, answers: dict) -> None: + """オーガナイザーへ送った設計の承認の答えを、持ち主のレーンが周回の境で処理する(#2436 の 6e・6f)。 + 承認ならラベル design-approved を付けて承認を書き、直して出し直すなら指摘を、やらないなら差し戻しを書く。""" + for w in [w for w in rec.get("waiting", []) if w.get("request") and not w.get("answer")]: + a = (answers.get(w["request"]) or {}).get("answer") + if not isinstance(a, dict): + continue + result = a.get("result") + if result == "approved" and w.get("pr"): + code, so, se = env.run(["gh", "pr", "edit", str(w["pr"]), "--add-label", "design-approved"], cwd=str(env.main_dir())) + if code != 0: + w["label_error"] = (se or so).strip()[:200] + if result in ("approved", "rejected", "revise"): + apply_answer(env, rec, w, result, w.get("pr"), a.get("note")) + + def cmd_approve_mvv(env: Env, name: str, milestone: str) -> dict: with open_record(env, name) as (rec, before): try: @@ -439,6 +483,49 @@ def cmd_lap(env: Env, name: str, failed: bool, done: str | None, deferred_issues return after_close(env, rec, before, f"周回 {lap['n']} を {outcome} で締めた") +def cmd_finish(env: Env, name: str, n: int, done: str, update: Callable | None = None) -> dict: + """保留の周回(配布待ち・質問待ち)の残りを流した done を読み、周回を 1 回だけ締め直す(#2436 の 6a・6d)。""" + with open_record(env, name) as (rec, before): + lap = rec["laps"][n - 1] if 1 <= n <= len(rec.get("laps", [])) else None + if not lap or lap.get("outcome") not in PENDING: + return refusal_order(f"周回 {n} は保留の周回でない(締め直しは 1 回だけ)") + if done not in lap.setdefault("dones", []): + lap["dones"].append(done) + refresh_state(lap, update) + state = jsonio.read(lap["state_path"], missing={}, broken={}, want=dict) if lap.get("state_path") else {} + return finish_held(env, rec, before, lap, read_done_items(done), state) + + +def cmd_pending(env: Env, name: str) -> dict: + """答えの届いた保留の周回のまとめの pipeline だけを返す(止まったレーンの残りを一時レーンが流す。異常系 11)。""" + with open_record(env, name) as (rec, before): + return pending_pipeline(env, rec, before) + + +def cmd_hold_question(env: Env, name: str, ref: str, plan: str, step: str, parallel: str | None = None) -> dict: + """作業の途中の問いを問いの hook がオーガナイザーへ送った周回を「質問待ち」で締め、止まったプランとステップを残して + 次の周回へ進む(#2436 の 6d)。答えは周回の境で読み、止まったプランを続きのステップから流す。""" + with open_record(env, name) as (rec, before): + lap = current_lap(rec) + if not lap: + return refusal_order("進行中の周回が無い") + lap["question"] = {"request": ref, "plan": plan, "step": step, "plan_name": parallel or lane_plan_name(env)} + close_lap(env, rec, lap, QUESTION_PENDING) + lap["held_at"] = lap["ended_at"] + return after_close(env, rec, before, f"周回 {lap['n']} の問い({ref})をオーガナイザーへ送り、質問待ちで締めた") + + +def lane_plan_name(env: Env) -> str | None: + """自分(NDF_RELAY_NAME)が属する動いている並列の計画の名前(無ければ None)。""" + try: + import parallel_lane + + got = parallel_lane.lane_of(env.main_dir()) + except Exception: # noqa: BLE001 — 計画を読めないときはレーンでないものとして扱う + return None + return got[0] if got else None + + # ---------------------------------------------------------------- status / list @@ -466,6 +553,12 @@ def cmd_status(env: Env, name: str) -> dict: waiting = [w["number"] for w in rec.get("waiting", []) if not w.get("answer")] if waiting: lines.append("人の判断待ち: " + " ".join(f"#{n}" for n in waiting)) + held = [ + {"lap": x["n"], "outcome": x["outcome"], "answer": ((x.get("release") or {}).get("answer") or {}).get("result")} + for x in pending_laps(rec) + ] + for h in held: + lines.append(f"周回 {h['lap']}: {h['outcome']}(答え: {h['answer'] or 'まだ'})") line, owner = owner_line(env) lines.append(line) metrics = { @@ -473,6 +566,7 @@ def cmd_status(env: Env, name: str) -> dict: "consumed": consumed(rec), "budget": rec.get("budget"), "waiting": waiting, + "held": held, "review_owner": owner, } if rec.get("stop"): diff --git a/plugins/ndf/scripts/lib/sprint_loop_cli.py b/plugins/ndf/scripts/lib/sprint_loop_cli.py index 2841da8de..4c20a4590 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_cli.py +++ b/plugins/ndf/scripts/lib/sprint_loop_cli.py @@ -28,13 +28,18 @@ def run_loop_command(a, update) -> dict | None: "exclude": lambda: sl_select.cmd_exclude(env, a.loop_name, a.issue, a.reason), "sprint": lambda: sl.cmd_sprint(env, a.loop_name, a.mode, a.dev, a.prod, list(a.extra)), "gate": lambda: sl_gate.cmd_gate(env, a.loop_name, a.done, update=update), - "answer": lambda: sl.cmd_answer(env, a.loop_name, a.issue, a.approved, a.pr), + "answer": lambda: sl.cmd_answer(env, a.loop_name, a.issue, a.approved, a.pr, revise=a.revise, note=a.note), "approve-mvv": lambda: sl.cmd_approve_mvv(env, a.loop_name, a.milestone), "lap": lambda: sl.cmd_lap(env, a.loop_name, a.failed, a.done, a.deferred_issues or [], update=update), + "finish": lambda: sl.cmd_finish(env, a.loop_name, a.lap, a.done, update=update), + "pending": lambda: sl.cmd_pending(env, a.loop_name), + "hold-question": lambda: sl.cmd_hold_question(env, a.loop_name, a.ref, a.plan, a.step, a.parallel), "stop": lambda: sl.cmd_stop(env, a.loop_name), "status": lambda: sl.cmd_status(env, a.loop_name), "list": lambda: sl.cmd_list(env, a.loop_name), } + if a.loop_cmd == "answer" and a.revise and not a.note: + return step_result.result(sl.TOOL, "stopped", "--revise には --note <指摘のファイル> が要る", [], {"status": "refused"}) out = handlers[a.loop_cmd]() if a.loop_cmd in ("status", "list") and not a.json: print( @@ -99,13 +104,24 @@ def name(p, optional=False): g = s.add_mutually_exclusive_group(required=True) g.add_argument("--approved", action="store_true") g.add_argument("--rejected", action="store_true") + g.add_argument("--revise", action="store_true", help="直して出し直す(--note の指摘を課題の本文に書き、次の周回で先頭に選ぶ)") s.add_argument("--pr", type=int) + s.add_argument("--note", help="--revise: 指摘を書いたファイル") s = name(ls.add_parser("approve-mvv")) s.add_argument("--milestone", required=True) s = name(ls.add_parser("lap")) s.add_argument("--failed", action="store_true", help="その周回を先へ進められない(失敗で締める)") s.add_argument("--done", help="周回の queue の done(承認ゲートで止まっていれば loop gate と同じに扱う)") s.add_argument("--deferred-issues", type=int, nargs="+", help="上限に達して Issue へ回した指摘の Issue 番号(決定 13)") + s = name(ls.add_parser("finish")) + s.add_argument("lap", type=int, help="締め直す保留の周回(配布待ち・質問待ち)") + s.add_argument("--done", required=True, help="残りの工程(まとめ)の queue の done") + name(ls.add_parser("pending")) + s = name(ls.add_parser("hold-question")) + s.add_argument("--ref", required=True, help="オーガナイザーへ送った問いの依頼の id") + s.add_argument("--plan", required=True, help="問いで止まったプラン") + s.add_argument("--step", required=True, help="答えの後に続けるステップ") + s.add_argument("--parallel", help="並列の計画名(省くと NDF_RELAY_NAME のレーンが属する計画)") name(ls.add_parser("stop")) for k in ("status", "list"): name(ls.add_parser(k)).add_argument("--json", action="store_true") diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index 2d676fea3..39fc2b7fb 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -25,6 +25,8 @@ IN_PROGRESS, FAILED, RETURNED, + RELEASE_PENDING, + PENDING, GATE_1, GATE_2, DESIGN_PHASE, @@ -37,6 +39,7 @@ current_lap, consumed, boundary_stop, + held_stop, mvv_change_stop, resume_command, stop_and_report, @@ -64,6 +67,7 @@ def select_cmd(name: str) -> str: QUEUE_MAX = 3 # まとめの queue の同時に流す計画の上限(supervise.py queue --max) +DONE_RESULTS = ("完了", NOT_RUN) # 周回を完了で締められる done の結果(流さなかった計画は前のステージの結果に従う) def queue_command(stages: list[list[str]], done: Path) -> str: @@ -198,7 +202,7 @@ def gate_copy(name: str, row: dict) -> dict: def waiting_section(rec: dict, w: dict) -> str: - how = f"python3 {SPRINT_STATE} loop answer {rec['name']} {w['number']} --approved|--rejected" + how = f"python3 {SPRINT_STATE} loop answer {rec['name']} {w['number']} --approved|--rejected|--revise --note <指摘のファイル>" pr = f"#{w['pr']}" if w.get("pr") else "—" lines = [ f"- 判定: {w.get('verdict', 'unknown')}(承認ゲート {w['gate']})", @@ -208,6 +212,8 @@ def waiting_section(rec: dict, w: dict) -> str: f"- 答え: {w.get('answer') or 'まだ'}", f"- 答え方: `{how}`", ] + if w.get("note"): # 直して出し直す(#2436 の 6e): 次の周回の設計がこの指摘を読む + lines += ["", "### 直して出し直す指摘", "", w["note"]] return "\n".join(lines) @@ -243,6 +249,9 @@ def handle_gate(env: Env, rec: dict, before: dict, lap: dict, items: list[dict]) return refusal_order("done に承認ゲートで止まった計画が無い") if stop := mvv_change_stop(env, rec): return stop_and_report(env, rec, before, stop, "プロジェクト MVV が改訂された(承認ゲートの機械のチェック)") + request = next((i["plan"] for i in gated if plan_info(i["plan"]).get("フェーズ") == RELEASE_REQUEST_PHASE), None) + if request: # 並列の計画のレーンが配布の依頼を送った: 承認ゲート 2 として止まらず、配布待ちで締めて次の周回へ(#2436 の 6a) + return handle_release_pending(env, rec, before, lap, request) gate1 = [i for i in gated if plan_info(i["plan"]).get("フェーズ") == DESIGN_PHASE] if len(gate1) != len(gated): return handle_gate2(env, rec, before, lap, next(i["plan"] for i in gated if i not in gate1)) @@ -287,6 +296,41 @@ def handle_gate2(env: Env, rec: dict, before: dict, lap: dict, plan: str | None return stop_and_report(env, rec, before, stop, "承認ゲート 2 を人へ戻した。本番を出さずに止まる(利用者の承認を求める)", {"gate": 2}) +RELEASE_REQUEST_PHASE = "配布の依頼" # parallel_release.RELEASE_REQUEST_PHASE(配布の依頼のプランの「フェーズ」) + + +def request_record(plan: str) -> tuple[Path, dict]: + """配布の依頼のプランの状態ディレクトリの `work/request.txt`(`parallel.py request` が書く)のパスと中身。""" + out = Path(plan).with_name(Path(plan).stem + "-state") / "work" / "request.txt" + return out, jsonio.read(out, missing={}, broken={}, want=dict) + + +def handle_release_pending(env: Env, rec: dict, before: dict, lap: dict, plan: str) -> dict: + """配布の依頼を送った周回を「配布待ち」で締める。まとめ(同じ queue の残りのステージと close)は流さずに記録へ残し、 + 答えを待たずに次の周回へ進む。答えは周回の境(`pending_step`)で読む。""" + out, sent = request_record(plan) + rest = [s for s in stages_after(lap, plan) if s and s != lap.get("close_plans")] + lap["release"] = { + "plan": plan, + "out": str(out), + "plan_name": sent.get("plan") or plan_info(plan).get("並列の計画"), + "request": sent.get("id"), + "stages": rest, + "close_plans": lap.get("close_plans") or [], + } + close_lap(env, rec, lap, RELEASE_PENDING) + lap["held_at"] = lap["ended_at"] + return after_close(env, rec, before, f"周回 {lap['n']} の配布の依頼({sent.get('id') or '識別子なし'})を送り、配布待ちで締めた") + + +def finish_lap(env: Env, rec: dict, lap: dict, outcome: str, state: dict | None = None) -> None: + """保留の周回(配布待ち・質問待ち)を 1 回だけ締め直す(I1 の例外)。結果・終わった時刻・費用と秒・振り返り・棚卸しを書き換える。""" + if lap.get("outcome") not in PENDING: + raise LoopError(f"周回 {lap['n']} は保留の周回でない(締め直しは 1 回だけ)") + lap["outcome"] = IN_PROGRESS + close_lap(env, rec, lap, outcome, state) + + def unfinished_design_issues(items: list[dict]) -> list[int]: """done のうち、設計のプランが完了も関門もしなかった(失敗・流さなかった)課題。設計と承認を経ていないため続きに入れない。""" out: set[int] = set() @@ -489,7 +533,7 @@ def close_lap(env: Env, rec: dict, lap: dict, outcome: str, state: dict | None = def after_close(env: Env, rec: dict, before: dict, summary: str) -> dict: """周回を締めた後の境の判定。止まれば 3、続けば ndf_next を返す。""" - stop = boundary_stop(env, rec) + stop = held_stop(rec, boundary_stop(env, rec)) if stop: return stop_and_report(env, rec, before, stop, f"{summary}。止まる条件に当たった({stop['condition']})") write_record(env, rec, before) diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate2.py b/plugins/ndf/scripts/lib/sprint_loop_gate2.py index e612ffba2..42acf2599 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate2.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate2.py @@ -1,4 +1,7 @@ -"""開発ループの承認ゲート 2 の答えの読み取りと、承認の後の続きのコマンド(sprint_loop から分けた)。""" +"""開発ループの承認ゲート 2 の答えの読み取りと、承認の後の続きのコマンド(sprint_loop から分けた)。 + +並列の計画のレーン(#2436)では、承認ゲート 2 の答えは配布の依頼の答えとして耐久の記録に届く。周回の境の `pending_step` が +保留の周回(配布待ち・質問待ち)と、オーガナイザーへ送った設計の承認の答えを待たずに読み、残りの工程(まとめ)を流す。""" from __future__ import annotations @@ -9,10 +12,25 @@ import gate_rejections as gr import jsonio -from sprint_loop_record import GATE_2, SUPERVISE, Env, selected_issues +from sprint_loop_record import ( + GATE_2, + SPRINT_STATE, + SUPERVISE, + FAILED, + RETURNED, + QUESTION_PENDING, + RELEASE_PENDING, + Env, + pending_laps, + selected_issues, + write_record, +) from sprint_loop_gate import ( + after_close, build_close, + finish_lap, gate2_resume_step, + loop_ok, plan_info, queue_command, stages_after, @@ -73,3 +91,147 @@ def gate2_pipeline(env: Env, rec: dict, lap: dict, stop: dict) -> str: return f"手で行う本番のステージ({plan} の後)を終えてから: {queue} && {wait_lap(rec, done)}" run = f"python3 {shlex.quote(str(SUPERVISE))} run {shlex.quote(plan)} --from {step}" return f"{run} && {queue} && {wait_lap(rec, done)}" + + +# ---------------------------------------------------------------- 保留の周回(#2436 の受け入れ条件 6a・6d・6e) + + +def pending_requests(rec: dict) -> dict[str, list[str]]: + """答えを読む依頼の id を計画名ごとに: 保留の周回の依頼と、オーガナイザーへ送った答えの無い設計の承認。""" + out: dict[str, list[str]] = {} + for lap in pending_laps(rec): + part = lap.get("release") if lap["outcome"] == RELEASE_PENDING else lap.get("question") + if part and part.get("request"): + out.setdefault(part.get("plan_name") or "", []).append(part["request"]) + for w in rec.get("waiting", []): + if w.get("request") and not w.get("answer"): + out.setdefault(w.get("plan") or "", []).append(w["request"]) + return out + + +def read_answers(env: Env, rec: dict) -> dict[str, dict]: + """依頼の id ごとの {"answer", "defer"}(待たない)。""" + got: dict[str, dict] = {} + for plan, ids in pending_requests(rec).items(): + got.update(env.lane_answers(plan, ids)) + return got + + +def answer_of(answers: dict, rid: str | None) -> dict | None: + a = (answers.get(rid or "") or {}).get("answer") + return a if isinstance(a, dict) else None + + +def finish_pipeline(env: Env, rec: dict, lap: dict) -> str: + """保留の周回の残り(質問待ちなら止まったプランの続き → 同じ queue の残りのステージ)とまとめを流し、 + `loop finish` で締め直す 1 行。組んだ行は記録へ残し、打ち直しでは同じ行を返す(まとめを二重に流さない)。""" + part = lap["release"] if lap["outcome"] == RELEASE_PENDING else lap["question"] + if (part.get("finish") or {}).get("pipeline"): + return part["finish"]["pipeline"] + done = Path(lap["dir"]) / f"done-{len(lap.get('dones', [])) + 1}.json" + lap.setdefault("dones", []).append(str(done)) + head = "" + if lap["outcome"] == RELEASE_PENDING: + stages = list(part.get("stages") or []) + else: + head = f"python3 {shlex.quote(str(SUPERVISE))} run {shlex.quote(part['plan'])} --from {shlex.quote(part['step'])} && " + stages = [st for st in stages_after(lap, part["plan"]) if st != lap.get("close_plans")] + stages = [st for st in [*stages, part.get("close_plans") or lap.get("close_plans") or []] if st] + finish = f"python3 {shlex.quote(str(SPRINT_STATE))} loop finish {rec['name']} {lap['n']} --done {shlex.quote(str(done))}" + wait = f"python3 {shlex.quote(str(SUPERVISE))} wait {shlex.quote(str(done))}" + body = f"{queue_command(stages, done)} && {wait} && " if stages else "" + pipeline = f"{head}{body}{finish}" + part["finish"] = {"started_at": env.now(), "done": str(done), "pipeline": pipeline} + return pipeline + + +def finish_result(env: Env, rec: dict, before: dict, lap: dict) -> dict: + pipeline = finish_pipeline(env, rec, lap) + write_record(env, rec, before) + return loop_ok( + f"周回 {lap['n']}({lap['outcome']})の答えが届いた。残りの工程を流してから次の周回へ進む", + rec, + [{"lap": lap["n"], "outcome": lap["outcome"]}], + status="finish", + next_=pipeline, + lap=lap["n"], + pipeline=pipeline, + ) + + +def answered_lap(rec: dict, answers: dict, results: tuple[str, ...] = ("released",)) -> dict | None: + """答えの届いた最も古い保留の周回(配布待ちは results の答え、質問待ちはどの答えでも)。""" + for lap in pending_laps(rec): + if lap["outcome"] == QUESTION_PENDING: + if answer_of(answers, (lap.get("question") or {}).get("request")): + return lap + elif (a := answer_of(answers, (lap.get("release") or {}).get("request"))) and a.get("result") in results: + return lap + return None + + +def settle_release(env: Env, rec: dict, lap: dict, a: dict) -> None: + """配布待ちの周回へ届いた答えを書く。差し戻しは人へ戻した、失敗は失敗で締め直す(まとめは流さない)。""" + lap["release"]["answer"] = {**a, "read_at": env.now()} + if a.get("result") == "released": + if a.get("version"): + lap.setdefault("versions", {})["prod"] = a["version"] + return + if a.get("result") == "rejected": + finish_lap(env, rec, lap, RETURNED) + rec.setdefault("fixes", []).append( + {"lap": lap["n"], "issues": lap.get("issues_kept") or selected_issues(lap), "reason": a.get("reason") or "", "at": env.now()} + ) + return + finish_lap(env, rec, lap, FAILED) + lap["failure"] = {"issues": lap.get("issues_kept") or selected_issues(lap), "kind": "配布", "step": "配布の失敗"} + + +def pending_step(env: Env, rec: dict, before: dict, apply_gate1=None) -> dict | None: + """周回の境で、答えの届いた依頼を古い順に扱う(境の 2〜6)。まとめを流す周回があればその結果を返し、無ければ None。""" + answers = read_answers(env, rec) + if apply_gate1 is not None: + apply_gate1(env, rec, answers) + for lap in pending_laps(rec): + a = answer_of(answers, (lap.get("release") or {}).get("request")) if lap["outcome"] == RELEASE_PENDING else None + if a and a.get("result") != "released": + settle_release(env, rec, lap, a) + lap = answered_lap(rec, answers) + if lap is None: + return None + if lap["outcome"] == RELEASE_PENDING and not (lap["release"].get("answer") or {}).get("result"): + settle_release(env, rec, lap, answer_of(answers, lap["release"]["request"])) + return finish_result(env, rec, before, lap) + + +def pending_pipeline(env: Env, rec: dict, before: dict) -> dict: + """`loop pending`: 答えの届いた保留の周回のまとめの pipeline だけを返す(止まったレーンの残りを一時レーンが流す。異常系 11)。""" + answers = read_answers(env, rec) + lap = answered_lap(rec, answers) + if lap is None: + return loop_ok("答えの届いた保留の周回は無い", rec, status="none") + if lap["outcome"] == RELEASE_PENDING and not (lap["release"].get("answer") or {}).get("result"): + settle_release(env, rec, lap, answer_of(answers, lap["release"]["request"])) + return finish_result(env, rec, before, lap) + + +def finish_held(env: Env, rec: dict, before: dict, lap: dict, items: list[dict], state: dict) -> dict: + """`loop finish`: 保留の周回の残りの done を読み、周回を 1 回だけ締め直して周回の境へ戻る。 + 質問待ちの続きが配布の依頼で止まったら、配布待ちへ移して答えを待つ。""" + from sprint_loop_gate import DONE_RESULTS, RELEASE_REQUEST_PHASE, handle_release_pending + + part = lap["release"] if lap["outcome"] == RELEASE_PENDING else lap["question"] + (part.setdefault("finish", {}))["ended_at"] = env.now() + request = next( + (i["plan"] for i in items if i.get("result") == "関門" and plan_info(i.get("plan") or "").get("フェーズ") == RELEASE_REQUEST_PHASE), + None, + ) + if lap["outcome"] == QUESTION_PENDING and request: + lap["outcome"] = "進行中" + return handle_release_pending(env, rec, before, lap, request) + bad = [i for i in items if i.get("unfilled") or i.get("result") not in DONE_RESULTS] + version = (lap.get("versions") or {}).get("prod") + finish_lap(env, rec, lap, FAILED if bad or not items else "完了", state) + if version and lap["outcome"] == "完了": + lap.setdefault("versions", {})["prod"] = version + return after_close(env, rec, before, f"周回 {lap['n']} の残りを流し、{lap['outcome']} で締め直した") diff --git a/plugins/ndf/scripts/lib/sprint_loop_record.py b/plugins/ndf/scripts/lib/sprint_loop_record.py index c3d86301d..456581654 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_record.py +++ b/plugins/ndf/scripts/lib/sprint_loop_record.py @@ -6,6 +6,9 @@ 承認ゲート 2 を人へ戻した(`gate2_returned`)・プロジェクト MVV が改訂された(`mvv_changed`)・周回を組めなかった (`refused`)の 3 つだけで、周回は進行中のまま残す。 +並列の計画のレーン(#2436)では、配布の依頼を送った周回を「配布待ち」、作業の途中の問いをオーガナイザーへ送った周回を +「質問待ち」で締め、答えを待たずに次の周回へ進む。保留の周回は、周回の境で答えを読んで 1 回だけ締め直す(I1 の例外)。 + 外の世界(gh・`upkeep.py rank`・`supervise.py`・`sprint-state.py` の別の副命令)は `Env` の関数を通して呼び、テストで 差し替える。 @@ -55,8 +58,11 @@ EXIT_STOPPED = step_result.EXIT_PRECONDITION # 3: 止まる条件に当たった(失敗ではない) IN_PROGRESS, DONE, FAILED, RETURNED = "進行中", "完了", "失敗", "人へ戻した" +RELEASE_PENDING, QUESTION_PENDING = "配布待ち", "質問待ち" # 答えを待たずに締めた保留の周回(#2436 の受け入れ条件 6a・6d) +PENDING = (RELEASE_PENDING, QUESTION_PENDING) +RELEASE_PENDING_MAX = 2 # 保留の周回の上限の既定(.ndf/pace.json の loop.release_pending_max) SELECTED, DROPPED = "選んだ", "外した" -BOUNDARY_CONDITIONS = ("user_stop", "mvv_changed", "same_failure", "budget_laps", "budget_usd", "budget_hours") +BOUNDARY_CONDITIONS = ("user_stop", "mvv_changed", "same_failure", "budget_laps", "budget_usd", "budget_hours", "waiting_only") SLOT_FIT = "当てた" # upkeep_rank_config.SLOT_FIT(rank の出力をそのまま読む) OVER_SCALE = "尺度を超える" # upkeep_rank_estimate.OVER_SCALE GATE_1, GATE_2 = "関門 1", "関門 2" @@ -139,6 +145,18 @@ class Env: upkeep_dir: Callable = _upkeep_state_dir now: Callable = field(default=lambda: clock.now_iso("utc")) main: Path | None = None + answers: Callable | None = None # (計画名, 依頼の id の並び) → {id: {"answer", "defer"}}。None は parallel_lane.answers_of + + def lane_answers(self, plan: str, ids: list[str]) -> dict: + """並列の計画の依頼の答えを待たずに読む(#2436)。読めなければ答えの無いものとして返す。""" + if self.answers is not None: + return self.answers(plan, ids) + try: + import parallel_lane + + return parallel_lane.answers_of(self.main_dir(), plan, ids) + except Exception: # noqa: BLE001 — 耐久の記録が無い・読めないときは答えがまだ無いものとして続ける + return {i: {"answer": None, "defer": None} for i in ids} def __post_init__(self) -> None: # gh は cwd の git remote から対象リポジトリを決める。既定の入口は root で動かし、--root の別リポジトリの @@ -186,10 +204,11 @@ def check_laps(rec: dict) -> None: def write_record(env: Env, rec: dict, before: dict | None = None) -> None: - """記録を書く。終わった周回の行を書き換える書き込みは拒む(I1)。`laps.md` も描き直す。""" + """記録を書く。終わった周回の行を書き換える書き込みは拒む(I1)。`laps.md` も描き直す。 + 保留の周回(配布待ち・質問待ち)の行は、答えを読んで締め直すまで書き換えてよい(I1 の例外)。""" check_laps(rec) if before is not None: - old = {lap["n"]: lap for lap in before.get("laps", []) if lap.get("outcome") != IN_PROGRESS} + old = {lap["n"]: lap for lap in before.get("laps", []) if lap.get("outcome") not in (IN_PROGRESS, *PENDING)} new = {lap["n"]: lap for lap in rec.get("laps", [])} changed = [n for n, lap in old.items() if new.get(n) != lap] if changed: @@ -219,6 +238,16 @@ def current_lap(rec: dict) -> dict | None: return next((lap for lap in rec.get("laps", []) if lap.get("outcome") == IN_PROGRESS), None) +def pending_laps(rec: dict) -> list[dict]: + """保留の周回(配布待ち・質問待ち)を古い順に。""" + return [lap for lap in rec.get("laps", []) if lap.get("outcome") in PENDING] + + +def pending_issues(rec: dict) -> set[int]: + """保留の周回の課題(締め直すまで選定から外す)。""" + return {n for lap in pending_laps(rec) for n in (lap.get("issues_kept") or selected_issues(lap))} + + # ---------------------------------------------------------------- 消費と止まる条件 @@ -237,14 +266,20 @@ def active_seconds(rec: dict) -> float: total = 0.0 stops = [(_loop_time(s.get("at")), _loop_time(s.get("resumed_at"))) for s in rec.get("stops", [])] for lap in rec.get("laps", []): - s, e = _loop_time(lap.get("started_at")), _loop_time(lap.get("ended_at")) - if not s or not e: - continue - span = max(0.0, (e - s).total_seconds()) - for a, b in stops: - if a and b: - span -= _overlap(s, e, a, b) - total += max(0.0, span) + # 保留の周回は、締めるまでの区間(held_at まで)と、答えの後に残りを流した区間(release.finish)を足す。 + # 周回の境で答えを待つ時間は数えない(#2436) + spans = [(lap.get("started_at"), lap.get("held_at") or lap.get("ended_at"))] + if lap.get("held_at"): + fin = (lap.get("release") or {}).get("finish") or (lap.get("question") or {}).get("finish") or {} + spans.append((fin.get("started_at"), fin.get("ended_at"))) + for s, e in ((_loop_time(a), _loop_time(b)) for a, b in spans): + if not s or not e: + continue + span = max(0.0, (e - s).total_seconds()) + for a, b in stops: + if a and b: + span -= _overlap(s, e, a, b) + total += max(0.0, span) return total @@ -270,7 +305,7 @@ def failure_streak(rec: dict) -> tuple[dict | None, int]: break if lap.get("outcome") == DONE: break - if lap.get("outcome") != FAILED: + if lap.get("outcome") != FAILED: # 保留の周回(配布待ち・質問待ち)は完了でも失敗でもないものとして読み飛ばす continue k = lap.get("failure") if key is None: @@ -308,6 +343,24 @@ def boundary_stop(env: Env, rec: dict) -> dict | None: return None +IMMEDIATE_STOPS = ("user_stop", "mvv_changed") # 保留の周回があっても待たずに止まる条件(#2436 の境の 1) + + +def waiting_only(rec: dict, why: str) -> dict: + """止まる理由 `waiting_only`(手元の作業が待ちだけ)。答えが届くとオーガナイザーが起こし、起こされた境で続きを流す。""" + laps = [{"lap": lap["n"], "outcome": lap["outcome"]} for lap in pending_laps(rec)] + asked = [w["number"] for w in rec.get("waiting", []) if w.get("request") and not w.get("answer")] + return {"condition": "waiting_only", "values": {"why": why, "pending": laps, "waiting": asked}} + + +def held_stop(rec: dict, stop: dict | None) -> dict | None: + """止まる条件(same_failure・予算)に当たっても、答えの無い保留の周回があれば先に waiting_only で止まる(境の 8)。 + 保留の周回のまとめを流さないまま止まると、課題が閉じずに残るためである。""" + if stop and stop["condition"] not in IMMEDIATE_STOPS and pending_laps(rec): + return waiting_only(rec, stop["condition"]) + return stop + + def resume_command(rec: dict) -> str: """再開のコマンド(ndf-next の中身。名前を必ず含め、終わりの条件の節を末尾に足す。決定 12 の B)。""" head = "/goal " if rec.get("goal", True) else "" diff --git a/plugins/ndf/scripts/lib/sprint_loop_select.py b/plugins/ndf/scripts/lib/sprint_loop_select.py index 69e782d5a..47237d344 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_select.py +++ b/plugins/ndf/scripts/lib/sprint_loop_select.py @@ -24,6 +24,12 @@ open_record, current_lap, boundary_stop, + held_stop, + pending_issues, + pending_laps, + waiting_only, + IMMEDIATE_STOPS, + RELEASE_PENDING_MAX, resume_command, stopped_result, stop_and_report, @@ -36,6 +42,7 @@ refusal_order, select_cmd, ) +from sprint_loop_gate2 import pending_step def review_first(env: Env, rec: dict, before: dict, got: dict) -> dict: @@ -52,7 +59,7 @@ def review_first(env: Env, rec: dict, before: dict, got: dict) -> dict: return loop_ok("選べる課題が尽きた。止まる前に見直しを 1 回挟む", rec, got["selection"], status="review_first", next_=nxt) -def candidates_of(ms: dict, max_issues: int) -> list[dict]: +def candidates_of(ms: dict, max_issues: int, held: set[int] = frozenset()) -> list[dict]: """マイルストーンの候補(順位の順): 境界までの行(境界が無ければ判断待ちを除いた先頭から max_issues 件まで)+大きい課題の枠の 1 切れ(当てたとき) +品質の枠の課題(宣言があるとき)。""" rows = sorted(ms.get("rows", []), key=lambda r: r["rank"]) @@ -62,8 +69,8 @@ def candidates_of(ms: dict, max_issues: int) -> list[dict]: elif b: idx = next((i for i, r in enumerate(rows) if r["number"] == b["number"]), len(rows) - 1) nums = {r["number"] for r in rows[: idx + 1]} - else: # 判断待ちの行は枠を使わない(選ばれない行で max_issues を埋めると、後ろの課題が候補に上がらない) - open_rows = [r for r in rows if not r.get("waiting_decision")][:max_issues] + else: # 判断待ちと保留の周回(held)の行は枠を使わない(選ばれない行で max_issues を埋めると、後ろの課題が候補に上がらない) + open_rows = [r for r in rows if not r.get("waiting_decision") and r["number"] not in held][:max_issues] last = rows.index(open_rows[-1]) if open_rows else len(rows) - 1 nums = {r["number"] for r in rows[: last + 1]} slot = ms.get("large_slot") or {} @@ -73,10 +80,22 @@ def candidates_of(ms: dict, max_issues: int) -> list[dict]: return [r for r in rows if r["number"] in nums] +PENDING_REASON = "保留の周回の課題" + + +def first_issues(rec: dict) -> set[int]: + """順位によらず先頭に選ぶ課題(#2436 の 6e): 設計を直して出し直す課題と、配布を差し戻された周回の直し。""" + last = {w["number"]: w for w in rec.get("waiting", [])} + revise = {n for n, w in last.items() if w.get("answer") == "revise"} + return revise | {n for f in rec.get("fixes", []) if not f.get("fixed_by") for n in f.get("issues") or []} + + def exclusion_reason(env: Env, rec: dict, row: dict, lap_n: int, lap_created: set) -> str | None: n = row["number"] if row.get("waiting_decision"): return "人の判断待ち(棚卸し)" + if n in pending_issues(rec): # 締め直すまで選ばない(選ぶと保留中の実装と作業が重なる。#2436 の 6a) + return PENDING_REASON for w in rec.get("waiting", []): if w["number"] != n: continue @@ -99,18 +118,25 @@ def exclusion_reason(env: Env, rec: dict, row: dict, lap_n: int, lap_created: se return None -def _select_candidate_rows(cands: list[dict], title: str, max_issues: int, reason_for) -> tuple[list[int], list[dict]]: +def _select_candidate_rows( + cands: list[dict], title: str, max_issues: int, reason_for, first: set[int] = frozenset() +) -> tuple[list[int], list[dict]]: """1 マイルストーンの候補の採否。選んだ番号と選定の行を返す。reason_for は外す理由(外さなければ None)を返す。 - max_issues の枠は品質の枠の課題に先に割り当て、残りを順位の順に埋める(品質の枠の最低件数を容量で削らない)。""" + max_issues の枠は、先頭に選ぶ課題(first。直して出し直す・配布の直し)→ 品質の枠の課題 → 順位の順に割り当てる + (品質の枠の最低件数を容量で削らない)。""" whys = [reason_for(r) for r in cands] # 候補ごとに 1 回、順位の順に呼ぶ(exclusion_reason の副作用の回数と順序を保つ) open_ = [i for i, w in enumerate(whys) if w is None] - take = set(([i for i in open_ if cands[i].get("quality_slot")] + [i for i in open_ if not cands[i].get("quality_slot")])[:max_issues]) + head = [i for i in open_ if cands[i]["number"] in first] + rest = [i for i in open_ if i not in head] + take_order = head + [i for i in rest if cands[i].get("quality_slot")] + [i for i in rest if not cands[i].get("quality_slot")] + take = take_order[:max_issues] picked, here = [], [] - for i, r in enumerate(cands): + for i in sorted(range(len(cands)), key=lambda i: (i not in head, i)): # 先頭に選ぶ課題だけを前へ。ほかは順位の順 + r = cands[i] why = whys[i] if whys[i] is not None or i in take else "容量" if why is None: picked.append(r["number"]) - reason = "品質の枠" if r.get("quality_slot") else "順位" + reason = "直しの先頭" if r["number"] in first else "品質の枠" if r.get("quality_slot") else "順位" here.append({"number": r["number"], "milestone": title, "rank": r["rank"], "decision": SELECTED, "reason": reason}) else: here.append({"number": r["number"], "milestone": title, "rank": r["rank"], "decision": DROPPED, "reason": why}) @@ -125,9 +151,12 @@ def choose_issues(env: Env, rec: dict, rank: dict, lap_n: int, descriptions: dic order = [t for t in m.get("order", []) if t in by_title and (not rec.get("milestones") or t in rec["milestones"])] approvals = {(a["milestone"], a["sha256"]) for a in rec.get("sprint_mvv_approvals", [])} rows_out, total = [], 0 + first = first_issues(rec) for title in order: ms = by_title[title] - cands = candidates_of(ms, rec["max_issues"]) + cands = candidates_of(ms, rec["max_issues"], pending_issues(rec)) + have = {r["number"] for r in cands} + cands += [r for r in sorted(ms.get("rows", []), key=lambda r: r["rank"]) if r["number"] in first and r["number"] not in have] total += len(cands) text = sprint_mvv.mvv_sections(descriptions.get(title) or "") unapproved = text is not None and (title, pm.sha256_text(text)) not in approvals @@ -136,6 +165,7 @@ def choose_issues(env: Env, rec: dict, rank: dict, lap_n: int, descriptions: dic title, rec["max_issues"], (lambda r: "スプリント MVV が未承認") if unapproved else (lambda r: exclusion_reason(env, rec, r, lap_n, lap_created)), + first, ) rows_out += here if picked: @@ -155,10 +185,31 @@ def cmd_select(env: Env, name: str) -> dict: return stopped_result(env, rec, f"ループ {name} は止まっている。再開のコマンド(loop start)で理由を判定し直す") if current_lap(rec): return refusal_order("進行中の周回がある(I2)。周回を締めてから選ぶ") - stop = boundary_stop(env, rec) - if stop: - return stop_and_report(env, rec, before, stop, f"止まる条件に当たった({stop['condition']})") - return run_selection(env, rec, before, len(rec["laps"]) + 1) + return boundary_then_select(env, rec, before) + + +def boundary_then_select(env: Env, rec: dict, before: dict) -> dict: + """周回の境(#2436 の lane-loop の「周回の境」の 1〜9): すぐ止まる条件 → 届いた答えの処理と保留の周回のまとめ → + 保留の上限・待ちだけ → ほかの止まる条件 → 選定。""" + stop = boundary_stop(env, rec) + if stop and stop["condition"] in IMMEDIATE_STOPS: + return stop_and_report(env, rec, before, stop, f"止まる条件に当たった({stop['condition']})") + import sprint_loop + + got = pending_step(env, rec, before, sprint_loop.apply_gate1_answers) + if got is not None: + return got + if len(pending_laps(rec)) >= rec.get("release_pending_max", RELEASE_PENDING_MAX): + return stop_and_report(env, rec, before, waiting_only(rec, "release_pending_max"), "答えの無い保留の周回が上限に達した(待ちだけ)") + stop = held_stop(rec, boundary_stop(env, rec)) + if stop: + return stop_and_report(env, rec, before, stop, f"止まる条件に当たった({stop['condition']})") + return run_selection(env, rec, before, len(rec["laps"]) + 1) + + +def has_held_work(rec: dict) -> bool: + """答えを待つ作業があるか(保留の周回か、オーガナイザーへ送って答えの無い設計の承認)。""" + return bool(pending_laps(rec)) or any(w.get("request") and not w.get("answer") for w in rec.get("waiting", [])) EXCLUDE_ENV = "NDF_LOOP_EXCLUDE" @@ -192,6 +243,8 @@ def run_selection(env: Env, rec: dict, before: dict, n: int, lap: dict | None = if got["milestone"] is None: if lap is not None: rec["laps"].remove(lap) # スプリントを組む前の周回は取り下げ、除外の一覧(excluded の lap)を次の選定に残す + if has_held_work(rec): # 選べる課題が無く答えを待つ作業だけが残る: no_issue で止まらず、見直しも挟まない(異常系 6) + return stop_and_report(env, rec, before, waiting_only(rec, "no_selectable"), "手元の作業が待ちだけになった", {"selection": got["selection"]}) cond = "no_issue" if got["candidates"] == 0 else "outside_mvv" if cond == "no_issue" and rec.get("review_first") != len(rec["laps"]): return review_first(env, rec, before, got) @@ -224,6 +277,9 @@ def run_selection(env: Env, rec: dict, before: dict, n: int, lap: dict | None = ) if lap is None: rec["laps"].append(new) + for f in rec.get("fixes", []): # 直しの周回: 差し戻された周回の課題を選んだら、その直しを済みにする + if not f.get("fixed_by") and set(f.get("issues") or []) & set(selected_issues(new)): + f["fixed_by"] = n if got["sprint_mvv"]: d.mkdir(parents=True, exist_ok=True) (d / "mvv.md").write_text(got["sprint_mvv"], encoding="utf-8") diff --git a/plugins/ndf/scripts/tests/test_pace.py b/plugins/ndf/scripts/tests/test_pace.py index 237c1f984..6196b6a18 100644 --- a/plugins/ndf/scripts/tests/test_pace.py +++ b/plugins/ndf/scripts/tests/test_pace.py @@ -107,7 +107,7 @@ def test_the_loop_section_is_read_with_defaults(tmp_path): assert pace.read_loop(write(tmp_path, DECL)) == pace.LOOP_DEFAULTS assert pace.read_loop(write(tmp_path, {**DECL, "loop": None})) == pace.LOOP_DEFAULTS d = pace.read_loop(write(tmp_path, {**DECL, "loop": {"enabled": True, "budget": {"laps": 5}, "max_issues": 1}})) - assert d == {"enabled": True, "budget": {"laps": 5, "usd": 60, "hours": 24}, "same_failure": 2, "max_issues": 1} + assert d == {"enabled": True, "budget": {"laps": 5, "usd": 60, "hours": 24}, "same_failure": 2, "max_issues": 1, "release_pending_max": 2} assert pace.read_loop(write(tmp_path, {**DECL, "loop": {"enabled": "yes"}}))["enabled"] is False diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index a070201f3..aa156679c 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -1266,3 +1266,204 @@ def cont(*a): out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) assert out["exit"] == 3 and "承認ゲート 1 の後の続きをまだ組めない: " in out["summary"] assert rec(env)["status"] == "stopped" + + +# ---------------- #2436 の受け入れ条件 6a・6d・6e: レーンの配布待ちと質問待ち ---------------- + +REQ = "lroot-r000000000001" + + +def lane_lap(env, nums=(11, 12), rid=REQ, rank=None): + """並列の計画のレーンの周回: 本番のステージが配布の依頼のプラン(フェーズ「配布の依頼」)で、依頼の記録がある。""" + env.rank_box["out"] = rank or rank_out(ms("01 近い", [11, 12, 13])) + env.answer_box = {} + env.answers = lambda plan, ids: {i: env.answer_box.get(i, {"answer": None, "defer": None}) for i in ids} + if not (env.root / ".ndf/loop/l1/loop.json").exists(): + start(env) + sl_select.cmd_select(env, "l1") + sl.cmd_sprint(env, "l1", "standard", "1.0.0-dev.1", None, []) + lap = sl.current_lap(rec(env)) + prod = Path(lap["out"]) / "prod.json" + prod.write_text(json.dumps({"フェーズ": "配布の依頼", "課題": list(nums), "並列の計画": "p1"}, ensure_ascii=False)) + work = prod.with_name("prod-state") / "work" + work.mkdir(parents=True, exist_ok=True) + (work / "request.txt").write_text(json.dumps({"id": rid, "plan": "p1", "lane": "lroot", "prs": [5]})) + return lap + + +def request_lap(env, rid=REQ): + lap = lane_lap(env, rid=rid) + set_state(env, lap) + out = sl.cmd_lap(env, "l1", False, write_done(env, [{"plan": str(Path(lap["out"]) / "prod.json"), "result": "関門"}]), []) + return lap, out + + +def test_release_request_closes_the_lap_as_release_pending_and_goes_on(env): + lap, out = request_lap(env) + assert out["status"] == "ok" and "ndf_next" in out["metrics"] # gate2_returned で止まらない + r = rec(env) + done = r["laps"][0] + assert done["outcome"] == "配布待ち" and r["status"] == "running" + assert done["release"]["plan_name"] == "p1" and done["release"]["request"] == REQ + assert done["release"]["close_plans"] # まとめは流さずに残す + assert sl.current_lap(r) is None + + +def test_issues_of_a_pending_lap_are_not_selected(env): + request_lap(env) + out = sl_select.cmd_select(env, "l1") + assert out["metrics"]["selected"] == [13] + why = {x["number"]: x["reason"] for x in out["items"]} + assert why[11] == why[12] == "保留の周回の課題" + + +def test_released_answer_runs_the_close_and_finish_closes_the_lap_with_the_version(env): + request_lap(env) + env.answer_box[REQ] = {"answer": {"result": "released", "version": "10.28.0"}, "defer": None} + out = sl_select.cmd_select(env, "l1") + assert out["metrics"]["status"] == "finish" and out["metrics"]["lap"] == 1 + pipe = out["metrics"]["pipeline"] + assert "1-close.json" in pipe and "loop finish l1 1 --done" in pipe + r = rec(env) + assert r["laps"][0]["release"]["answer"]["version"] == "10.28.0" and len(r["laps"]) == 1 # 新しい周回を先に始めない + d = r["laps"][0]["release"]["finish"]["done"] + Path(d).write_text(json.dumps({"items": [{"plan": "x", "result": "完了"}]})) + fin = sl.cmd_finish(env, "l1", 1, d) + lap = rec(env)["laps"][0] + assert fin["status"] == "ok" and lap["outcome"] == "完了" and lap["versions"]["prod"] == "10.28.0" + assert sl.cmd_finish(env, "l1", 1, d)["exit"] == 1 # 締め直しは 1 回だけ + + +def test_two_answered_pending_laps_are_finished_oldest_first(env): + request_lap(env, rid="lroot-r000000000001") + env.rank_box["out"] = rank_out(ms("01 近い", [13, 14])) + lap2 = lane_lap(env, nums=(13,), rid="lroot-r000000000002", rank=rank_out(ms("01 近い", [13, 14]))) + set_state(env, lap2) + sl.cmd_lap(env, "l1", False, write_done(env, [{"plan": str(Path(lap2["out"]) / "prod.json"), "result": "関門"}]), []) + for rid in ("lroot-r000000000001", "lroot-r000000000002"): + env.answer_box[rid] = {"answer": {"result": "released", "version": "10.28.0"}, "defer": None} + assert sl_select.cmd_select(env, "l1")["metrics"]["lap"] == 1 + + +def test_rejected_answer_returns_the_lap_without_the_close(env): + request_lap(env) + env.answer_box[REQ] = {"answer": {"result": "rejected", "reason": "直す"}, "defer": None} + out = sl_select.cmd_select(env, "l1") + r = rec(env) + assert r["laps"][0]["outcome"] == "人へ戻した" and "finish" not in r["laps"][0]["release"] + assert r["fixes"][0]["lap"] == 1 and r["fixes"][0]["reason"] == "直す" + assert out["metrics"]["status"] == "selected" and out["metrics"]["selected"][0] in (11, 12) # 直しの周回が先頭に選ぶ + + +def test_failed_answer_closes_the_lap_as_failed(env): + request_lap(env) + env.answer_box[REQ] = {"answer": {"result": "failed", "reason": "CI"}, "defer": None} + sl_select.cmd_select(env, "l1") + lap = rec(env)["laps"][0] + assert lap["outcome"] == "失敗" and lap["failure"]["kind"] == "配布" + + +def test_defer_keeps_the_lap_pending(env): + request_lap(env) + env.answer_box[REQ] = {"answer": None, "defer": {"seq": 1, "reason": "次の配布へ"}} + out = sl_select.cmd_select(env, "l1") + assert rec(env)["laps"][0]["outcome"] == "配布待ち" and out["metrics"]["selected"] == [13] + + +def test_waiting_only_when_pending_laps_reach_the_limit(env): + request_lap(env, rid="lroot-r000000000001") + lap2 = lane_lap(env, nums=(13,), rid="lroot-r000000000002", rank=rank_out(ms("01 近い", [13, 14]))) + set_state(env, lap2) + sl.cmd_lap(env, "l1", False, write_done(env, [{"plan": str(Path(lap2["out"]) / "prod.json"), "result": "関門"}]), []) + out = sl_select.cmd_select(env, "l1") + assert out["exit"] == 3 and out["metrics"]["stop"]["condition"] == "waiting_only" and len(rec(env)["laps"]) == 2 + + +def test_waiting_only_instead_of_no_issue_when_only_pending_work_is_left(env): + request_lap(env) + env.rank_box["out"] = rank_out(ms("01 近い", [11, 12])) + out = sl_select.cmd_select(env, "l1") + assert out["exit"] == 3 and out["metrics"]["stop"]["condition"] == "waiting_only" + + +def test_budget_waits_first_but_user_stop_stops_at_once(env): + request_lap(env) + r = rec(env) + r["budget"]["laps"] = 1 + sl.write_record(env, r) + assert sl_select.cmd_select(env, "l1")["metrics"]["stop"]["condition"] == "waiting_only" + sl.cmd_start(env, "l1", [], {}) + sl.cmd_stop(env, "l1") + assert sl_select.cmd_select(env, "l1")["metrics"]["stop"]["condition"] == "user_stop" + + +def test_pending_counts_as_a_lap_in_the_budget_but_not_as_a_failure(env): + request_lap(env) + r = rec(env) + assert sl.consumed(r)["laps"] == 1 and sl_record.failure_streak(r) == (None, 0) + + +def test_loop_pending_returns_only_the_answered_close(env): + request_lap(env) + assert sl.cmd_pending(env, "l1")["metrics"]["status"] == "none" + env.answer_box[REQ] = {"answer": {"result": "released", "version": "10.28.0"}, "defer": None} + out = sl.cmd_pending(env, "l1") + assert out["metrics"]["status"] == "finish" and "loop finish l1 1" in out["metrics"]["pipeline"] + + +def test_hold_question_closes_the_lap_and_the_answer_resumes_the_stopped_plan(env): + lap = lane_lap(env) + impl = str(Path(lap["out"]) / "impl-11.json") + out = sl.cmd_hold_question(env, "l1", "lroot-q1", impl, "fix") + r = rec(env) + assert out["status"] == "ok" and r["laps"][0]["outcome"] == "質問待ち" and r["laps"][0]["question"]["request"] == "lroot-q1" + assert sl_select.cmd_select(env, "l1")["metrics"]["selected"] == [13] # 答えの前は流さない + sl.cmd_sprint(env, "l1", "standard", "1.0.0-dev.2", None, []) + set_state(env, sl.current_lap(rec(env))) + sl.cmd_lap(env, "l1", False, None, []) + env.answer_box["lroot-q1"] = {"answer": {"result": "answered", "choice": ["A"]}, "defer": None} + got = sl_select.cmd_select(env, "l1") + pipe = got["metrics"]["pipeline"] + assert got["metrics"]["status"] == "finish" and f"run {impl} --from fix" in pipe and "loop finish l1 1" in pipe + + +def test_answer_revise_keeps_the_note_and_selects_the_issue_first(env, tmp_path): + env.rank_box["out"] = rank_out(ms("01 近い", [12, 11])) + start(env) + sl_select.cmd_select(env, "l1") + sl.cmd_sprint(env, "l1", "standard", "1.0.0-dev.1", None, []) + lap = sl.current_lap(rec(env)) + sl_gate.cmd_gate(env, "l1", write_done(env, [{"plan": str(Path(lap["out"]) / f"design-{n}.json"), "result": "関門"} for n in (11, 12)])) + sl.cmd_answer(env, "l1", 12, True, 501) + note = tmp_path / "note.md" + note.write_text("API の形を変える") + out = sl.cmd_answer(env, "l1", 11, False, None, revise=True, note=str(note)) + w = next(w for w in rec(env)["waiting"] if w["number"] == 11) + assert out["status"] == "ok" and w["answer"] == "revise" and w["note"] == "API の形を変える" and w["revise_count"] == 1 + assert "API の形を変える" in env.bodies[11] + r = rec(env) + assert sl_select.exclusion_reason(env, r, {"number": 11}, 2, set()) is None + assert sl.current_lap(r) is None # 両方の課題を人の判断待ちへ移した周回は締まっている + env.rank_box["out"] = rank_out(ms("01 近い", [13, 14, 11])) + assert sl_select.cmd_select(env, "l1")["metrics"]["selected"][0] == 11 + + +def test_gate1_answers_are_applied_by_the_lane_at_the_boundary(env): + env.rank_box["out"] = rank_out(ms("01 近い", [11, 12, 13])) + env.answer_box = {} + env.answers = lambda plan, ids: {i: env.answer_box.get(i, {"answer": None, "defer": None}) for i in ids} + start(env) + sl_select.cmd_select(env, "l1") + sl.cmd_sprint(env, "l1", "standard", "1.0.0-dev.1", None, []) + lap = sl.current_lap(rec(env)) + sl_gate.cmd_gate(env, "l1", write_done(env, [{"plan": str(Path(lap["out"]) / "design-11.json"), "result": "関門"}])) + r = rec(env) + r["waiting"][0].update(request="lroot-g501-abc", plan="p1", pr=501) + sl.write_record(env, r) + set_state(env, sl.current_lap(r)) + sl.cmd_lap(env, "l1", False, None, []) + env.answer_box["lroot-g501-abc"] = {"answer": {"result": "approved"}, "defer": None} + sl_select.cmd_select(env, "l1") + w = rec(env)["waiting"][0] + assert w["answer"] == "approved" and w["pr"] == 501 + assert any(c[:3] == ["gh", "pr", "edit"] and "design-approved" in c for c in env.calls) From 53acbe18b74e6554b1ae9d8d8bab717c0a1c84c3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 07:53:49 +0000 Subject: [PATCH 26/70] =?UTF-8?q?feat(release):=20=E6=9C=AC=E7=95=AA?= =?UTF-8?q?=E3=81=AE=20Pull=20Request=20=E3=81=AE=20head=20=E3=82=92=20rel?= =?UTF-8?q?ease/v<=E7=89=88>=20=E3=81=AB=E3=81=97=E3=80=81=E6=89=BF?= =?UTF-8?q?=E8=AA=8D=E3=81=97=E3=81=9F=E3=82=B3=E3=83=9F=E3=83=83=E3=83=88?= =?UTF-8?q?=E3=81=A8=20release/v<=E7=89=88>=20=E3=82=92=E7=85=A7=E5=90=88?= =?UTF-8?q?=E3=81=99=E3=82=8B=EF=BC=88#2436=20=E3=81=AE=20R2=E3=80=9CR5?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 承認の照合の相手を origin/release/v<版> にし、承認の後に起点のブランチが進んでも止めない - release/v<版> → 起点が衝突すれば release/v<版>-sync で版数だけを本番の版で解いて取り込み、照合には元の PR を渡す - 本番の PR がマージ済みなら照合とマージを飛ばし、タグと GitHub Release のうち無いものだけを作る - record は release/v<版> の PR がマージされていなければ -sync の PR へ書く Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/release-steps.py | 170 +++++++++++---- plugins/ndf/scripts/release_lib/approval.py | 13 +- .../tests/test_release_approved_commit.py | 197 +++++++++++++----- .../ndf/scripts/tests/test_release_record.py | 13 ++ .../ndf/scripts/tests/test_release_steps.py | 10 +- .../tests/test_release_version_merge.py | 2 +- 6 files changed, 301 insertions(+), 104 deletions(-) diff --git a/plugins/ndf/scripts/release-steps.py b/plugins/ndf/scripts/release-steps.py index a26667517..a8f3e11c6 100755 --- a/plugins/ndf/scripts/release-steps.py +++ b/plugins/ndf/scripts/release-steps.py @@ -37,10 +37,15 @@ python3 release-steps.py notes --version <版> --prs ... [--approval <提示物>] [--verified claude,codex] [--ref <ブランチ>] [--plugin <名前>] [--root ] -release --channel prod は承認したコミットを受け取り、配布の PR をマージした後の `origin/<ベースブランチ>` の先端と比べ、 -承認の外の変更があれば本番チャネルの PR もタグも作らずに承認ゲート(10)で止まる(release_lib/approval.py。#815)。 - -record は release --channel prod の後に、本番のリリースの PR(release/v<版> → ベースブランチ)へリリース記録 +release は release/v<版> をベースブランチへマージのコミットで取り込む。版数を持つ箇所だけが衝突すれば +release/v<版>-sync で本番の版を取って取り込み、ほかが衝突すれば 1 で止まる(release_lib/version_merge.py。#2436 の R5)。 +release --channel prod は承認したコミットを受け取り、`origin/release/v<版>` の先端と比べ、承認の外の変更があれば +本番チャネルの PR もタグも作らずに承認ゲート(10)で止まる(release_lib/approval.py。#815)。承認の後にベースブランチが +進んでも止まらず、本番チャネルの PR は head を release/v<版> にして比べた先端だけを出す(#2436 の R2)。本番チャネルの +PR がマージ済みなら、比較とマージを飛ばし、タグと GitHub Release のうち無いものだけを作る(途中で止まった配布の再開)。 + +record は release --channel prod の後に、本番のリリースの PR(release/v<版> → ベースブランチ。マージ済みが無ければ +release/v<版>-sync → ベースブランチ)へリリース記録 (`## 配布の記録`。形は lib/dist_record.py)をコメントで 1 件書く。タグと GitHub Release が無ければ書かずに 3 で止まり、 同じ記録が既にあれば書かずに ok を返す。投稿の失敗は 1、PR を読めなければ 2。metrics.release_pr_url を プランの `pr_from` が報告の Pull Request へ移す(#1273)。 @@ -88,6 +93,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from merged_lib import trash # noqa: E402 from release_lib import approval, bump, carry, changed, deploy, included, instructions, materials, others, record, replaced, step_run # noqa: E402 +from release_lib import version_merge # noqa: E402 from release_lib.approval import HeadMoved # noqa: E402 from release_lib.names import changelog_section, changelog_span, h2_lines, next_h2, plugin_of, release_decl # noqa: E402 from step_result import ( @@ -660,11 +666,23 @@ def wait_and_merge(root, n, expect=None, ci_wait=3600.0): if p.returncode != 0: if any(isinstance(i, dict) and i.get("result") == "head_moved" for i in out.get("items") or []): raise HeadMoved(out.get("summary") or "") + if p.returncode == EXIT_CONFLICT: + raise Conflicted(f"PR #{n} をマージできない: {out.get('summary') or p.stderr.strip()[:300]}") code = EXIT_INFRA_WAIT if p.returncode == EXIT_INFRA_WAIT else 1 raise StepError(f"PR #{n} をマージできない: {out.get('summary') or p.stderr.strip()[:300]}", code) return merge_commit_of(root, n) +EXIT_CONFLICT = 4 # merge-when-green: PR がベースブランチと衝突している + + +class Conflicted(StepError): + """PR がベースブランチと衝突してマージできない(merge-when-green の 4)。呼び手が扱わなければ 1 で止まる。""" + + def __init__(self, msg: str): + super().__init__(msg, 1) + + def merge_commit_of(root, n): d = pr_view(root, n, "mergeCommit") or {} return ((d.get("mergeCommit") or {}).get("oid")) or None @@ -703,30 +721,80 @@ def _release_pr_body(root, a, plugin, plugins): def _merge_release_pr(root, a, plugin, plugins, branch, base): - """開発版の PR(release/v<版> → ベースブランチ)を使い回すか作ってマージし、PR 番号とマージコミットを返す。""" + """開発版の PR(release/v<版> → ベースブランチ)を使い回すか作ってマージし、(照合に渡す PR, 取り込んだ PR, マージコミット) + を返す。衝突すれば release/v<版>-sync で取り込み(_merge_sync_pr)、照合に渡す PR は元の release/v<版> の PR のまま。""" pr = _reusable_release_pr(root, branch, base) if pr is None: body = _release_pr_body(root, a, plugin, plugins) - pr = {"number": create_pr(root, base, branch, f"Release: {plugin} v{a.version}", body), "state": "OPEN"} + pr = {"number": create_pr(root, base, branch, f"Release: {plugin} v{a.version}", body), "state": "OPEN", "new": True} n = pr["number"] - return n, (wait_and_merge(root, n, ci_wait=a.ci_wait) if pr["state"] == "OPEN" else merge_commit_of(root, n)) + if pr["state"] != "OPEN": + return n, n, merge_commit_of(root, n) + if not pr.get("new") and (synced := _merged_sync_pr(root, branch, base)): + return n, synced, merge_commit_of(root, synced) # 再開: 前の回が -sync で取り込み済み + try: + return n, n, wait_and_merge(root, n, ci_wait=a.ci_wait) + except Conflicted: + sync_n, commit = _merge_sync_pr(root, a, plugin, branch, base, n) + return n, sync_n, commit + + +def _merged_sync_pr(root, branch, base): + """release/v<版>-sync → ベースブランチのマージ済みの PR があり、release/v<版> の先頭がベースブランチの祖先ならその番号。""" + pr = find_pr(root, f"{branch}-sync", base, states=("MERGED",)) + if pr is None: + return None + git(root, "fetch", "-q", "origin", base) + return pr["number"] if git(root, "merge-base", "--is-ancestor", "HEAD", f"origin/{base}", check=False).returncode == 0 else None -def _merge_prod_pr(root, a, plugin, base, prod, release_pr, approved, allowed, verdict, items, metrics): - """本番の PR を使い回すか作り、承認と比べた先端を指定してマージする。先端が動いたら比べ直し、許可リストの下に - 収まる限り HEAD_MOVED_RETRIES 回まで入れ直し、それ以外は承認ゲートで止める。(PR 番号, マージコミット, 判定) を返す。""" - mp = find_pr(root, base, prod, states=("OPEN",)) +def _merge_sync_pr(root, a, plugin, branch, base, release_pr): + """release/v<版>-sync に origin/<ベースブランチ> を取り込み(版数だけの衝突は本番の版を取る。R5)、 + release/v<版>-sync → ベースブランチの PR でマージする。release/v<版> そのものへはベースブランチを取り込まない。 + 待つ間にベースブランチが進んで再び衝突すれば、-sync の先頭へ積み直す(強制 push はしない)。(PR 番号, マージコミット) を返す。""" + sync = f"{branch}-sync" + for left in range(HEAD_MOVED_RETRIES, -1, -1): + git(root, "fetch", "-q", "origin", base) + have = git(root, "ls-remote", "--exit-code", "--heads", "origin", f"refs/heads/{sync}", check=False) + ours = "HEAD" + if have.returncode == 0: + git(root, "fetch", "-q", "origin", sync) + ours = f"origin/{sync}" + resolved = [] + if git(root, "merge-base", "--is-ancestor", f"origin/{base}", ours, check=False).returncode != 0: + msg = f"Merge {base} into {sync}({plugin} v{a.version} の版数の衝突を本番の版で解く)" + commit, resolved = version_merge.sync_commit(root, ours, f"origin/{base}", msg) + git(root, "push", "-q", "origin", f"{commit}:refs/heads/{sync}") + found = find_pr(root, sync, base, states=("OPEN",)) + body = ( + f"{plugin} v{a.version} の release/v{a.version} → {base}(#{release_pr})が版数を持つ箇所で衝突したため、" + f"本番の版を取って取り込む(解いたパス: {', '.join(resolved) or 'なし'})。\n\n" + "🤖 Generated with [Claude Code](https://claude.com/claude-code)" + ) + n = found["number"] if found else create_pr(root, base, sync, f"Release: {plugin} v{a.version} の版数を {base} へ揃える", body) + try: + return n, wait_and_merge(root, n, ci_wait=a.ci_wait) + except Conflicted: + if not left: + raise + + +def _merge_prod_pr(root, a, plugin, branch, prod, release_pr, approved, allowed, verdict, items, metrics): + """本番の PR(head は release/v<版>)を使い回すか作り、承認と比べた release/v<版> の先端を指定してマージする。 + 先端が動いたら比べ直し、許可リストの下に収まる限り HEAD_MOVED_RETRIES 回まで入れ直し、それ以外は承認ゲートで止める。 + (PR 番号, マージコミット, 判定) を返す。""" + mp = find_pr(root, branch, prod, states=("OPEN",)) title = f"Release: {plugin} v{a.version} を {prod} へ" body = f"{plugin} v{a.version} を {prod} へ出す(開発版の PR #{release_pr})。\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)" - main_pr = mp["number"] if mp else create_pr(root, prod, base, title, body) + main_pr = mp["number"] if mp else create_pr(root, prod, branch, title, body) for left in range(HEAD_MOVED_RETRIES, -1, -1): try: merge = wait_and_merge(root, main_pr, expect=verdict.tip, ci_wait=a.ci_wait) # 比べた先端だけを本番チャネルへ入れる break except HeadMoved: - moved = approval.check_approved(root, approved, base, allowed) + moved = approval.check_approved(root, approved, branch, allowed) if not moved.ok or not left: - emit(approval.gate(root, a, plugin, base, moved, items, metrics, moved=not left), EXIT_GATE) + emit(approval.gate(root, a, plugin, branch, moved, items, metrics, moved=not left), EXIT_GATE) verdict = moved # 進んだ分が許可リストの下に収まるので、比べ直した先端で入れ直す(#2221) return main_pr, merge, verdict @@ -747,44 +815,53 @@ def cmd_release(a): bump.require_bumped(root, plugin, ver) git(root, "push", "-q", "-u", "origin", "HEAD") - release_pr, release_commit = _merge_release_pr(root, a, plugin, plugins, branch, base) + release_pr, merged_pr, release_commit = _merge_release_pr(root, a, plugin, plugins, branch, base) metrics = { "channel": a.channel, "version": ver, "release_pr": release_pr, + "sync_pr": merged_pr if merged_pr != release_pr else None, "main_pr": None, "tag": None, "merge_commit": release_commit, "plugins": ",".join(plugins), } items = [{"kind": "pr", "name": f"#{release_pr}", "result": "merged", "base": base}] + if metrics["sync_pr"]: + items[0]["result"] = "compared" # 取り込みは -sync の PR が行い、元の PR は照合にだけ使う + items.append({"kind": "pr", "name": f"#{merged_pr}", "result": "merged", "base": base, "head": f"{branch}-sync"}) if a.channel == "dev": emit(result(TOOL, "ok", f"{plugin} v{ver} を {base} へ出した(#{release_pr})", items, metrics)) - # 本番: ベースブランチ → 本番チャネル、タグ、GitHub Release(利用者の承認を得てから呼ぶ) + # 本番: release/v<版> → 本番チャネル、タグ、GitHub Release(利用者の承認を得てから呼ぶ) tag = f"{plugin}--v{ver}" git(root, "fetch", "-q", "origin", "--tags") - if git(root, "rev-parse", "-q", "--verify", f"refs/tags/{tag}", check=False).returncode == 0: - raise StepError(f"タグ {tag} は既にある") - # 承認したコミットの後に、配布の PR の外の変更がベースブランチへ入っていないか(#815) - allowed = approval.release_pr_allowed(root, release_pr, pr_view) - verdict = approval.check_approved(root, approved, base, allowed) - if not verdict.ok: - emit(approval.gate(root, a, plugin, base, verdict, items, metrics), EXIT_GATE) - metrics.update({"approved_sha": approved, "compared_head": verdict.tip, "tolerated": len(verdict.tolerated)}) - main_pr, merge, verdict = _merge_prod_pr(root, a, plugin, base, prod, release_pr, approved, allowed, verdict, items, metrics) - metrics.update({"compared_head": verdict.tip, "tolerated": len(verdict.tolerated)}) - items += approval.tolerated_items(verdict) - git(root, "fetch", "-q", "origin") - merge = merge or git(root, "rev-parse", f"origin/{prod}").stdout.strip() - _publish_tag_and_release(root, plugin, ver, tag, merge) - + done = find_pr(root, branch, prod, states=("MERGED", "OPEN")) + if done and done["state"] == "MERGED": + # 再開: 本番チャネルへのマージは済んでいる(取り消せない)。比べ直さず、無いものだけを作って続ける + main_pr = done["number"] + git(root, "fetch", "-q", "origin") + merge = merge_commit_of(root, main_pr) or git(root, "rev-parse", f"origin/{prod}").stdout.strip() + metrics["approved_sha"] = approved + items.append({"kind": "pr", "name": f"#{main_pr}", "result": "already_merged", "base": prod}) + else: + if git(root, "rev-parse", "-q", "--verify", f"refs/tags/{tag}", check=False).returncode == 0: + raise StepError(f"タグ {tag} は既にある") + # 承認したコミットの後に、版上げの外のコミットが release/v<版> に無いか(#815・#2436 の R2)。 + # 照合に渡すのは元の release/v<版> の PR で、-sync の PR は渡さない(承認の後のベースブランチの変更を含むため) + allowed = approval.release_pr_allowed(root, release_pr, pr_view) + verdict = approval.check_approved(root, approved, branch, allowed) + if not verdict.ok: + emit(approval.gate(root, a, plugin, branch, verdict, items, metrics), EXIT_GATE) + metrics.update({"approved_sha": approved, "compared_head": verdict.tip, "tolerated": len(verdict.tolerated)}) + main_pr, merge, verdict = _merge_prod_pr(root, a, plugin, branch, prod, release_pr, approved, allowed, verdict, items, metrics) + metrics.update({"compared_head": verdict.tip, "tolerated": len(verdict.tolerated)}) + items += approval.tolerated_items(verdict) + items.append({"kind": "pr", "name": f"#{main_pr}", "result": "merged", "base": prod}) + git(root, "fetch", "-q", "origin") + merge = merge or git(root, "rev-parse", f"origin/{prod}").stdout.strip() + items += _publish_tag_and_release(root, plugin, ver, tag, merge) metrics.update({"main_pr": main_pr, "tag": tag, "merge_commit": merge}) - items += [ - {"kind": "pr", "name": f"#{main_pr}", "result": "merged", "base": prod}, - {"kind": "tag", "name": tag, "result": "pushed"}, - {"kind": "release", "name": tag, "result": "created"}, - ] swept, sweep_metrics, nxt = _sweep_candidates(root, merge) items += swept metrics.update(sweep_metrics) @@ -792,10 +869,19 @@ def cmd_release(a): def _publish_tag_and_release(root, plugin, ver, tag, merge): - """本番チャネルへ入ったコミットにタグを打って push し、CHANGELOG.md の節を本文に GitHub Release を作る。""" - git(root, "tag", "-a", tag, merge, "-m", f"{plugin} v{ver}") - git(root, "push", "-q", "origin", tag) + """本番チャネルへ入ったコミットにタグを打って push し、CHANGELOG.md の節を本文に GitHub Release を作る。 + origin にあるタグと GitHub Release は作り直さない(再開)。items(tag・release の 1 件ずつ)を返す。""" + items = [] + if git(root, "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/{tag}", check=False).returncode == 0: + items.append({"kind": "tag", "name": tag, "result": "exists"}) + else: + if git(root, "rev-parse", "-q", "--verify", f"refs/tags/{tag}", check=False).returncode != 0: + git(root, "tag", "-a", tag, merge, "-m", f"{plugin} v{ver}") + git(root, "push", "-q", "origin", tag) + items.append({"kind": "tag", "name": tag, "result": "pushed"}) + if gh_parts.gh(["release", "view", tag, "--json", "tagName"], cwd=root).returncode == 0: + return items + [{"kind": "release", "name": tag, "result": "exists"}] notes = changelog_section(root, ver, plugin) or f"{plugin} v{ver}" with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as f: f.write(notes + "\n") @@ -806,6 +892,7 @@ def _publish_tag_and_release(root, plugin, ver, tag, merge): os.unlink(notes_file) if p.returncode != 0: raise StepError(f"gh release create {tag} が失敗: {p.stderr.strip()[:300]}") + return items + [{"kind": "release", "name": tag, "result": "created"}] def _sweep_candidates(root, merge): @@ -838,9 +925,10 @@ def cmd_record(a): git(root, "fetch", "-q", "origin", "--tags", check=False) # 直前の正式版とタグの時刻を読む if gh_parts.gh(["release", "view", tag, "--json", "tagName"], cwd=root).returncode != 0: raise StepError(f"GitHub Release {tag} が無い(本番の配布が済んでいない)", EXIT_PRECONDITION) - pr = find_pr(root, f"release/v{ver}", base, states=("MERGED",)) + # 版数の衝突を -sync で取り込んだときは、元の PR がマージされずに閉じていることがある(#2436) + pr = find_pr(root, f"release/v{ver}", base, states=("MERGED",)) or find_pr(root, f"release/v{ver}-sync", base, states=("MERGED",)) if pr is None: - raise StepError(f"release/v{ver} → {base} のマージ済みの PR が無い", EXIT_PRECONDITION) + raise StepError(f"release/v{ver}(か release/v{ver}-sync)→ {base} のマージ済みの PR が無い", EXIT_PRECONDITION) n = pr["number"] prev_tag = release_tag_before(root, plugin, current=tag) prev = prev_tag[len(f"{plugin}--v") :] if prev_tag else None diff --git a/plugins/ndf/scripts/release_lib/approval.py b/plugins/ndf/scripts/release_lib/approval.py index 7fe783dd8..44d9179dc 100644 --- a/plugins/ndf/scripts/release_lib/approval.py +++ b/plugins/ndf/scripts/release_lib/approval.py @@ -96,11 +96,11 @@ def release_pr_allowed(root, n, pr_view): return ac.AllowedPR(n, head, commits, (d.get("mergeCommit") or {}).get("oid")) -def check_approved(root, approved, base, allowed): - """origin/ の先端を読み直し、承認したコミットと比べた判定(Verdict)を返す。 +def check_approved(root, approved, ref, allowed): + """origin/(本番の配布では release/v<版>。#2436 の R2)の先端を読み直し、承認したコミットと比べた判定(Verdict)を返す。 差分がすべて許可リスト(UNGATED)の下に収まる承認の外のコミットは止めずに Verdict.tolerated へ入れる(#2221)。""" - git(root, "fetch", "-q", "origin", base) - tip = git(root, "rev-parse", f"origin/{base}").stdout.strip() + git(root, "fetch", "-q", "origin", f"+refs/heads/{ref}:refs/remotes/origin/{ref}") + tip = git(root, "rev-parse", f"origin/{ref}").stdout.strip() if allowed is None: return ac.Verdict(False, approved, tip, ac.UNDECIDABLE) return ac.compare_approved(root, approved, tip, [allowed], UNGATED) @@ -121,7 +121,7 @@ def commit_pr(root, sha): ac.UNKNOWN_COMMIT: "承認したコミット {a} を読めない", ac.NOT_ANCESTOR: "承認したコミット {a} が {base} の先端 {t} の祖先でない", ac.OUTSIDE_COMMITS: "承認したコミット {a} の後に承認の外のコミットが {n} 件ある", - ac.TREE_DIFFERS: "{base} の先端 {t} の中身が、承認したコミット {a} に配布の PR を足したものと違う", + ac.TREE_DIFFERS: "{base} の先端 {t} の中身が、承認したコミット {a} に版上げの PR を足したものと違う", ac.UNDECIDABLE: "承認したコミット {a} の後に承認の外の変更が無いかを判定できない", "head_moved": "{base} の先端が比べた後に {t} へ進んだ", } @@ -133,7 +133,8 @@ def tolerated_items(verdict) -> list[dict]: def gate(root, a, plugin, base, verdict, items, metrics, moved=False) -> dict: - """承認の外の変更で本番への配布を止める結果(status: gate。出すのは呼び手で終了コード 10。#815 の I2〜I6)。""" + """承認の外の変更で本番への配布を止める結果(status: gate。出すのは呼び手で終了コード 10。#815 の I2〜I6)。 + base は比べた参照(本番の配布では release/v<版>)の名前で、文言にだけ使う。""" reason = "head_moved" if moved and verdict.ok else verdict.reason out = [] for o in verdict.outside: diff --git a/plugins/ndf/scripts/tests/test_release_approved_commit.py b/plugins/ndf/scripts/tests/test_release_approved_commit.py index 662e997ca..96501517e 100644 --- a/plugins/ndf/scripts/tests/test_release_approved_commit.py +++ b/plugins/ndf/scripts/tests/test_release_approved_commit.py @@ -49,7 +49,7 @@ def clone(origin: Path, dest: Path) -> Path: @pytest.fixture def world(tmp_path: Path): - """origin(bare)・作業場所(release/v1.2.3)・別の clone(ほかの人のマージ)と、承認したコミット。""" + """origin(bare)・作業場所(承認したコミットから切った release/v1.2.3)・別の clone(ほかの人のマージ)と、承認したコミット。""" origin = tmp_path / "origin.git" subprocess.run(["git", "init", "-q", "--bare", "-b", "develop", str(origin)], check=True) seed = tmp_path / "seed" @@ -66,33 +66,69 @@ def world(tmp_path: Path): approved = commit(seed, "feature.txt", "f\n", "feature (#10)") git(seed, "push", "-q", "origin", "HEAD:trunk") work = clone(origin, tmp_path / "work") - git(work, "checkout", "-q", "-b", f"release/v{VER}", "origin/trunk") - commit(work, "version.txt", f"{VER}\n", f"bump {VER}") other = clone(origin, tmp_path / "other") git(other, "checkout", "-q", "trunk") - return {"origin": origin, "work": work, "other": other, "approved": approved} + w = {"origin": origin, "work": work, "other": other, "approved": approved} + cut_release(w) + return w + + +def cut_release(w): + """作業場所に release/v1.2.3 を origin/trunk の先頭から切り(切り直し)、版上げを積む。先頭を返す。""" + work = w["work"] + git(work, "fetch", "-q", "origin") + git(work, "checkout", "-q", "-B", f"release/v{VER}", "origin/trunk") + return commit(work, "version.txt", f"{VER}\n", f"bump {VER}") SHIPPED_FILE = "plugins/foo/statusline.txt" DOC_FILE = "docs/design.md" +SYNC = f"release/v{VER}-sync" -def run_release(monkeypatch, w, *, outside_after=None, move_head=None, **extra): +def run_release(monkeypatch, w, *, outside_after=None, move_head=None, dev_bump=None, prod_merged=False, published=(), **extra): """release --channel prod を流し、(結果, 終了コード, マージした PR と expect, gh の呼び出し) を返す。 - outside_after は配布の PR の後に、move_head は本番チャネルへのマージを待つ間に(1 回だけ)別の人が入れるファイルのパス。""" + outside_after は配布の PR の後に起点のブランチへ、move_head は本番チャネルへのマージを待つ間に(1 回だけ) + release/v<版> へ別の人が入れるファイルのパス。dev_bump は承認の後に起点のブランチへ入る version.txt の中身 + (配布の PR と衝突する)。prod_merged は本番の PR がマージ済みの再開、published はすでにある tag / release。 + PR の番号は 1 = release/v<版> → trunk、2 = release/v<版> → live、3 = release/v<版>-sync → trunk。""" mod = load(monkeypatch) work, other = w["work"], w["other"] - merged, ghcalls, state = [], [], {} + if dev_bump: + commit(other, "version.txt", dev_bump, "dev bump (#700)") + git(other, "push", "-q", "origin", "HEAD:trunk") + merged, ghcalls, created, state = [], [], [], {} monkeypatch.setattr(mod.bump, "require_bumped", lambda r, plugin, ver: None) - monkeypatch.setattr(mod, "find_pr", lambda r, head, base, states: None) - monkeypatch.setattr(mod, "run_checks", lambda r: []) - monkeypatch.setattr(mod, "create_pr", lambda r, base, head, title, body: 1 if base == "trunk" else 2) + if prod_merged: + state["live"] = git(work, "rev-parse", "HEAD") + git(work, "push", "-q", "origin", f"HEAD:release/v{VER}", "HEAD:live") + git(other, "fetch", "-q", "origin") + git(other, "merge", "-q", "--no-ff", "-m", "Merge pull request #1", f"origin/release/v{VER}") + git(other, "push", "-q", "origin", "HEAD:trunk") + for name in published: + if name == "tag": + git(work, "tag", "-a", f"foo--v{VER}", state["live"], "-m", "t") + git(work, "push", "-q", "origin", f"foo--v{VER}") + git(work, "tag", "-d", f"foo--v{VER}") + + def find_pr(r, head, base, states): + if prod_merged and head == f"release/v{VER}": + return {"number": 1 if base == "trunk" else 2, "state": "MERGED"} + return None + + def create_pr(r, base, head, title, body): + created.append((base, head)) + return {("trunk", f"release/v{VER}"): 1, ("live", f"release/v{VER}"): 2, ("trunk", SYNC): 3}[(base, head)] def wait_and_merge(r, n, expect=None, ci_wait=3600.0): merged.append((n, expect)) - if n == 1: + if n in (1, 3): git(other, "fetch", "-q", "origin") - git(other, "merge", "-q", "--no-ff", "-m", "Merge pull request #1", f"origin/release/v{VER}") + git(other, "reset", "-q", "--hard", "origin/trunk") + src = f"origin/release/v{VER}" if n == 1 else f"origin/{SYNC}" + if subprocess.run(["git", "-C", str(other), "merge", "-q", "--no-ff", "-m", f"Merge pull request #{n}", src]).returncode: + git(other, "merge", "--abort") + raise mod.Conflicted(f"PR #{n} がベースブランチと衝突している") state["merge"] = git(other, "rev-parse", "HEAD") if outside_after: commit(other, outside_after, "x\n", "statusline (#806)") @@ -100,24 +136,34 @@ def wait_and_merge(r, n, expect=None, ci_wait=3600.0): return state["merge"] if move_head and not state.get("moved"): state["moved"] = True + git(other, "fetch", "-q", "origin") + git(other, "checkout", "-q", "-B", "rel", f"origin/release/v{VER}") commit(other, move_head, "x\n", "late (#900)") - git(other, "push", "-q", "origin", "HEAD:trunk") + git(other, "push", "-q", "origin", f"HEAD:release/v{VER}") + git(other, "checkout", "-q", "trunk") raise mod.HeadMoved("moved") - git(other, "push", "-q", "origin", f"{expect}:live") + assert expect == git(w["origin"], "rev-parse", f"release/v{VER}") # 比べた release/v<版> の先頭だけを出す + git(w["origin"], "update-ref", "refs/heads/live", expect) + state["live"] = expect return expect def pr_view(r, n, fields): head = git(work, "rev-parse", "HEAD") - return {"headRefOid": head, "commits": [{"oid": head}], "mergeCommit": {"oid": state.get("merge")}} + return {"headRefOid": head, "commits": [{"oid": head}], "mergeCommit": {"oid": state.get("live" if n == 2 else "merge")}} + + def gh(args, cwd=None): + ghcalls.append(args) + if args[:2] == ["release", "view"]: + return subprocess.CompletedProcess(args, 0 if "release" in published else 1, "", "") + return subprocess.CompletedProcess(args, 0, '[{"number": 806}]', "") + monkeypatch.setattr(mod, "find_pr", find_pr) + monkeypatch.setattr(mod, "run_checks", lambda r: []) + monkeypatch.setattr(mod, "create_pr", create_pr) monkeypatch.setattr(mod, "wait_and_merge", wait_and_merge) monkeypatch.setattr(mod, "pr_view", pr_view) monkeypatch.setattr(mod, "changelog_section", lambda r, ver, plugin: "") - monkeypatch.setattr( - mod.gh_parts, - "gh", - lambda args, cwd=None: ghcalls.append(args) or subprocess.CompletedProcess(args, 0, '[{"number": 806}]', ""), - ) + monkeypatch.setattr(mod.gh_parts, "gh", gh) monkeypatch.setattr(mod.trash, "sweep", lambda r, m: ([], {})) ns = { "root": str(work), @@ -132,73 +178,122 @@ def pr_view(r, n, fields): with pytest.raises(SystemExit) as e: mod.cmd_release(argparse.Namespace(**ns)) out, code = e.value.code - return out, code, merged, ghcalls + return out, code, merged, ghcalls, created def tags(origin: Path) -> str: return git(origin, "tag") +def shipped(w, path: str) -> bool: + return subprocess.run(["git", "-C", str(w["origin"]), "cat-file", "-e", f"live:{path}"]).returncode == 0 + + def test_release_after_only_the_release_pr_is_ok(monkeypatch, world): - """受け入れ条件 3: 承認の後が配布の PR だけなら、比べた先端を本番チャネルへ入れてタグと Release を作る。""" - out, code, merged, ghcalls = run_release(monkeypatch, world, approved_sha=world["approved"]) - tip = git(world["origin"], "rev-parse", "trunk") + """受け入れ条件 3・#2436 の R2: 承認したコミットと版上げだけの release/v<版> を head に本番チャネルへ入れ、タグと Release を作る。""" + out, code, merged, ghcalls, created = run_release(monkeypatch, world, approved_sha=world["approved"]) + tip = git(world["origin"], "rev-parse", f"release/v{VER}") assert (out["status"], code) == ("ok", None) assert out["metrics"]["approved_sha"] == world["approved"] and out["metrics"]["compared_head"] == tip assert merged == [(1, None), (2, tip)] # 比べた先端だけをマージさせる(受け入れ条件 6) + assert created == [("trunk", f"release/v{VER}"), ("live", f"release/v{VER}")] # 本番の PR の head は release/v<版> + assert git(world["origin"], "merge-base", "--is-ancestor", tip, "trunk") == "" # R3・R4: 先に起点へ取り込んである assert tags(world["origin"]) == f"foo--v{VER}" assert any(a[:2] == ["release", "create"] for a in ghcalls) -def test_release_with_an_outside_pr_after_approval_stops_at_the_gate(monkeypatch, world): - """受け入れ条件 5: 配布の PR の外の変更があれば、本番チャネルの PR・タグ・Release を作らずに 10。""" - out, code, merged, ghcalls = run_release(monkeypatch, world, outside_after=SHIPPED_FILE, approved_sha=world["approved"]) +def test_release_after_the_base_moved_ships_only_the_approved_commit(monkeypatch, world): + """#2436 の R2: 承認の後に起点のブランチが進んでも止まらず、進んだ分は本番チャネルへ入れない(次の配布)。""" + out, code, merged, _, _ = run_release(monkeypatch, world, outside_after=SHIPPED_FILE, approved_sha=world["approved"]) + assert (out["status"], code) == ("ok", None) + assert not shipped(world, SHIPPED_FILE) and shipped(world, "version.txt") + assert tags(world["origin"]) == f"foo--v{VER}" + + +def test_release_cut_after_the_approved_commit_stops_at_the_gate(monkeypatch, world): + """#2436 の R2: release/v<版> に承認の外のコミットがあれば、本番チャネルの PR・タグ・Release を作らずに 10。""" + late = commit(world["other"], SHIPPED_FILE, "x\n", "statusline (#806)") + git(world["other"], "push", "-q", "origin", "HEAD:trunk") + cut_release(world) + out, code, merged, ghcalls, created = run_release(monkeypatch, world, approved_sha=world["approved"]) assert (out["status"], code) == ("gate", 10) - assert merged == [(1, None)] and tags(world["origin"]) == "" + assert merged == [(1, None)] and tags(world["origin"]) == "" and len(created) == 1 assert not any(a[:2] == ["release", "create"] for a in ghcalls) bad = [i for i in out["items"] if i.get("result") == "unapproved"] - assert [(i["subject"], i["pr"]) for i in bad] == [("statusline (#806)", 806)] and len(bad[0]["name"]) == 40 + assert [(i["name"], i["subject"], i["pr"]) for i in bad] == [(late, "statusline (#806)", 806)] m = out["metrics"] assert (m["approved_sha"], m["reason"], m["outside"], m["main_pr"], m["tag"]) == (world["approved"], "outside_commits", 1, None, None) - assert m["compared_head"] == git(world["origin"], "rev-parse", "trunk") + assert m["compared_head"] == git(world["origin"], "rev-parse", f"release/v{VER}") assert "approval-facts" in out["next"] and "--approved-sha" in out["next"] def test_release_stops_when_the_head_moves_while_waiting(monkeypatch, world): - """受け入れ条件 6: 比べた後に先端が進めば、本番チャネルへマージせず 10。""" - out, code, merged, _ = run_release(monkeypatch, world, move_head=SHIPPED_FILE, approved_sha=world["approved"]) + """受け入れ条件 6: 比べた後に release/v<版> の先頭が進めば、本番チャネルへマージせず 10。""" + out, code, merged, _, _ = run_release(monkeypatch, world, move_head=SHIPPED_FILE, approved_sha=world["approved"]) assert (out["status"], code) == ("gate", 10) - assert git(world["origin"], "rev-parse", "live") != git(world["origin"], "rev-parse", "trunk") + assert git(world["origin"], "rev-parse", "live") != git(world["origin"], "rev-parse", f"release/v{VER}") assert tags(world["origin"]) == "" and out["metrics"]["reason"] == "outside_commits" -def test_release_after_an_outside_pr_that_ships_nothing_is_ok(monkeypatch, world): - """#2221: 承認の後に入ったのが配布物に触れない PR だけなら、承認を取り直さずに本番チャネルへ入れ、通したコミットを残す。""" - out, code, merged, _ = run_release(monkeypatch, world, outside_after=DOC_FILE, approved_sha=world["approved"]) - tip = git(world["origin"], "rev-parse", "trunk") - assert (out["status"], code) == ("ok", None) - assert merged == [(1, None), (2, tip)] and tags(world["origin"]) == f"foo--v{VER}" - assert [i["subject"] for i in out["items"] if i.get("result") == "outside_unshipped"] == ["statusline (#806)"] - assert out["metrics"]["tolerated"] == 1 - - def test_release_retries_when_the_head_moves_by_a_change_that_ships_nothing(monkeypatch, world): - """#2221: 待つ間に配布物に触れないコミットで先端が進めば、比べ直した先端で入れ直す。""" - out, code, merged, _ = run_release(monkeypatch, world, move_head=DOC_FILE, approved_sha=world["approved"]) - tip = git(world["origin"], "rev-parse", "trunk") + """#2221: 待つ間に配布物に触れないコミットで先端が進めば、比べ直した先端で入れ直し、通したコミットを残す。""" + out, code, merged, _, _ = run_release(monkeypatch, world, move_head=DOC_FILE, approved_sha=world["approved"]) + tip = git(world["origin"], "rev-parse", f"release/v{VER}") assert (out["status"], code) == ("ok", None) assert merged[-1] == (2, tip) and len(merged) == 3 assert git(world["origin"], "rev-parse", "live") == tip and out["metrics"]["compared_head"] == tip + assert [i["subject"] for i in out["items"] if i.get("result") == "outside_unshipped"] == ["late (#900)"] def test_release_with_a_commit_that_is_not_an_ancestor_stops_at_the_gate(monkeypatch, world): """受け入れ条件 7: 先端の祖先でない・無い SHA は 10。""" - out, code, merged, _ = run_release(monkeypatch, world, approved_sha="f" * 40) + out, code, merged, _, _ = run_release(monkeypatch, world, approved_sha="f" * 40) assert (out["status"], code, out["metrics"]["reason"]) == ("gate", 10, "unknown_commit") assert merged == [(1, None)] and tags(world["origin"]) == "" assert {"kind": "commit", "name": "f" * 40, "result": "unknown"} in out["items"] +def test_release_with_a_version_conflict_merges_through_the_sync_branch(monkeypatch, world): + """#2436 の版数の衝突: 起点の版数が上がっていれば release/v<版>-sync で本番の版を取って取り込み、 + 照合には元の release/v<版> の PR を使い、本番の PR の head は release/v<版> のまま。""" + out, code, merged, _, created = run_release(monkeypatch, world, dev_bump="1.2.4-dev.1\n", approved_sha=world["approved"]) + tip = git(world["origin"], "rev-parse", f"release/v{VER}") + assert (out["status"], code) == ("ok", None) + assert merged == [(1, None), (3, None), (2, tip)] + assert created == [("trunk", f"release/v{VER}"), ("trunk", SYNC), ("live", f"release/v{VER}")] + assert (out["metrics"]["release_pr"], out["metrics"]["sync_pr"]) == (1, 3) + assert git(world["origin"], "merge-base", "--is-ancestor", tip, "trunk") == "" # R1: release/v<版> の先頭は起点の祖先 + assert git(world["origin"], "show", "trunk:version.txt") == VER # 本番の版を取った + assert git(world["origin"], "rev-parse", f"{SYNC}^1") == tip # release/v<版> そのものへは起点を取り込まない + assert git(world["origin"], "show", "live:version.txt") == VER + + +def test_release_with_a_conflict_outside_the_versions_stops(monkeypatch, world): + """#2436 の R5: 版数の外が衝突すれば解かずに 1 で止め、本番の PR を作らない。""" + mod_err = None + try: + run_release(monkeypatch, world, dev_bump="something else\n", approved_sha=world["approved"]) + except Exception as e: # noqa: BLE001 + mod_err = e + assert mod_err is not None and getattr(mod_err, "code", None) == 1 and "version.txt" in str(mod_err) + assert git(world["origin"], "rev-parse", "live") != git(world["origin"], "rev-parse", f"release/v{VER}") + assert tags(world["origin"]) == "" + + +@pytest.mark.parametrize("published", [(), ("tag",), ("tag", "release")]) +def test_release_after_the_production_pr_merged_makes_only_what_is_missing(monkeypatch, world, published): + """#2436 の再開: 本番の PR がマージ済みなら照合とマージを飛ばし、タグと GitHub Release のうち無いものだけを作る。""" + out, code, merged, ghcalls, created = run_release( + monkeypatch, world, prod_merged=True, published=published, approved_sha=world["approved"] + ) + assert (out["status"], code) == ("ok", None) + assert merged == [] and created == [] + assert tags(world["origin"]) == f"foo--v{VER}" + assert git(world["origin"], "rev-parse", f"foo--v{VER}^{{commit}}") == git(world["origin"], "rev-parse", "live") + assert any(a[:2] == ["release", "create"] for a in ghcalls) == ("release" not in published) + assert out["metrics"]["main_pr"] == 2 + + @pytest.mark.parametrize( "extra, code, text", [ @@ -244,7 +339,7 @@ def test_approve_then_release_with_the_material(monkeypatch, world, tmp_path): """I10: --approval は承認の記録があり SHA が同じときだけ受け取る。記録が無ければ何もマージせずに 10。""" path = tmp_path / "approval.md" path.write_text(MATERIAL.format(sha=world["approved"])) - out, code, merged, _ = run_release(monkeypatch, world, approval=str(path)) + out, code, merged, _, _ = run_release(monkeypatch, world, approval=str(path)) assert (out["status"], code, merged) == ("gate", 10, []) assert out["items"] == [{"kind": "commit", "name": world["approved"], "result": "not_approved", "recorded": None}] @@ -255,7 +350,7 @@ def test_approve_then_release_with_the_material(monkeypatch, world, tmp_path): with pytest.raises(SystemExit) as ok: mod.cmd_approve(argparse.Namespace(root=str(world["work"]), approval=str(path), approved_sha=world["approved"], by="user")) assert ok.value.code[0]["status"] == "ok" - out, code, merged, _ = run_release(monkeypatch, world, approval=str(path)) + out, code, merged, _, _ = run_release(monkeypatch, world, approval=str(path)) assert (out["status"], out["metrics"]["approved_sha"]) == ("ok", world["approved"]) @@ -317,7 +412,7 @@ def test_approve_from_facts_needs_the_users_answer_after_facts(monkeypatch, worl for answer in (None, answer_state(tmp_path, "2000-01-01T00:00:00Z")): out, code = approve_facts(mod, world, path, facts, answer) assert (out["status"], code) == ("gate", 10) - out, code, merged, _ = run_release(monkeypatch, world, approval=str(path)) + out, code, merged, _, _ = run_release(monkeypatch, world, approval=str(path)) assert (out["status"], merged) == ("gate", []) # release へ進んでも承認の記録が無いため止まる mod = load(monkeypatch) out, code = approve_facts(mod, world, path, facts, answer_state(tmp_path, "2999-01-01T00:00:00Z")) @@ -325,7 +420,7 @@ def test_approve_from_facts_needs_the_users_answer_after_facts(monkeypatch, worl # 記録が既にあれば、証跡を渡さなくても書かずに通す(--state の無いスプリントで conductor が approve を打った後) out, code = approve_facts(mod, world, path, facts) assert (out["status"], out["items"][0]["result"]) == ("ok", "kept") - out, code, merged, _ = run_release(monkeypatch, world, approval=str(path)) + out, code, merged, _, _ = run_release(monkeypatch, world, approval=str(path)) assert (out["status"], out["metrics"]["approved_sha"]) == ("ok", world["approved"]) diff --git a/plugins/ndf/scripts/tests/test_release_record.py b/plugins/ndf/scripts/tests/test_release_record.py index aa6836cff..7f10a668c 100644 --- a/plugins/ndf/scripts/tests/test_release_record.py +++ b/plugins/ndf/scripts/tests/test_release_record.py @@ -181,6 +181,19 @@ def test_record_before_the_production_release_posts_nothing(repo, gh, missing): assert not any(c[:2] == ["pr", "comment"] for c in gh.get().get("calls", [])) +def test_record_goes_to_the_sync_pr_when_the_release_pr_was_not_merged(repo, gh): + """#2436 の版数の衝突: release/v<版> の PR が閉じたまま -sync の PR で取り込んだときは、-sync の PR へ書く。""" + gh.set( + prs=[ + {"number": 39, "state": "CLOSED", "head": "release/v1.2.3", "base": "develop"}, + {"number": 40, "state": "MERGED", "head": "release/v1.2.3-sync", "base": "develop"}, + ] + ) + code, out, err = record(repo, gh.env) + assert code == 0, (out, err) + assert out["metrics"]["release_pr"] == 40 and len(gh.comments()) == 1 + + def test_record_failure_to_post_is_one(repo, gh): """AC4: gh pr comment が非 0 なら 1 で止まる(プランは record の失敗として judge-record へ進む)。""" gh.set(comment_code=1) diff --git a/plugins/ndf/scripts/tests/test_release_steps.py b/plugins/ndf/scripts/tests/test_release_steps.py index 05cc8a195..8f85ca2da 100644 --- a/plugins/ndf/scripts/tests/test_release_steps.py +++ b/plugins/ndf/scripts/tests/test_release_steps.py @@ -737,7 +737,7 @@ def _release_prod(monkeypatch, root: Path, wt: dict, plugin: str): def git(r, *args, check=True): calls.append(args) - rc = 1 if args[:3] == ("rev-parse", "-q", "--verify") else 0 + rc = 1 if args[:3] == ("rev-parse", "-q", "--verify") or args[0] == "ls-remote" else 0 # タグはまだ無い out = "release/v1.2.3" if args[:2] == ("rev-parse", "--abbrev-ref") else "" return subprocess.CompletedProcess(args, rc, out, "") @@ -765,8 +765,8 @@ def git(r, *args, check=True): def test_release_takes_branches_and_names_from_the_declaration(monkeypatch, tmp_path): """#1336 の AC10: ベースブランチと本番チャネルを変えた宣言では、その名前で PR を作りタグを打つ(develop・main の直書きで動かない)。""" found, created, calls, out = _release_prod(monkeypatch, tmp_path, {"base_branch": "trunk", "production_branch": "live"}, "foo") - assert found == [("release/v1.2.3", "trunk"), ("trunk", "live")] - assert created == [("trunk", "release/v1.2.3", "Release: foo v1.2.3"), ("live", "trunk", "Release: foo v1.2.3 を live へ")] + assert found == [("release/v1.2.3", "trunk"), ("release/v1.2.3", "live"), ("release/v1.2.3", "live")] + assert created == [("trunk", "release/v1.2.3", "Release: foo v1.2.3"), ("live", "release/v1.2.3", "Release: foo v1.2.3 を live へ")] assert ("tag", "-a", "foo--v1.2.3", "m-2", "-m", "foo v1.2.3") in calls assert out["metrics"]["tag"] == "foo--v1.2.3" and [i["base"] for i in out["items"] if i["kind"] == "pr"] == ["trunk", "live"] @@ -774,8 +774,8 @@ def test_release_takes_branches_and_names_from_the_declaration(monkeypatch, tmp_ def test_release_with_the_ai_plugins_declaration_keeps_its_values(monkeypatch, tmp_path): """#1336 の I9: ai-plugins の宣言では、ブランチ develop / main・タグ ndf--v<版>・題 Release: ndf v<版> が今と同じに出る。""" found, created, calls, out = _release_prod(monkeypatch, tmp_path, {"base_branch": "develop", "production_branch": "main"}, "ndf") - assert found == [("release/v1.2.3", "develop"), ("develop", "main")] - assert created == [("develop", "release/v1.2.3", "Release: ndf v1.2.3"), ("main", "develop", "Release: ndf v1.2.3 を main へ")] + assert found == [("release/v1.2.3", "develop"), ("release/v1.2.3", "main"), ("release/v1.2.3", "main")] + assert created == [("develop", "release/v1.2.3", "Release: ndf v1.2.3"), ("main", "release/v1.2.3", "Release: ndf v1.2.3 を main へ")] assert out["metrics"]["tag"] == "ndf--v1.2.3" diff --git a/plugins/ndf/scripts/tests/test_release_version_merge.py b/plugins/ndf/scripts/tests/test_release_version_merge.py index 7fef3d34c..0136ac046 100644 --- a/plugins/ndf/scripts/tests/test_release_version_merge.py +++ b/plugins/ndf/scripts/tests/test_release_version_merge.py @@ -61,7 +61,7 @@ def branch_release(root: Path, files: dict) -> str: def test_hunks_that_differ_only_in_versions_take_the_release_side(): - text = "x\n<<<<<<< ours\n \"version\": \"10.28.0\",\n=======\n \"version\": \"10.28.0-dev.4\",\n>>>>>>> theirs\ny\n" + text = 'x\n<<<<<<< ours\n "version": "10.28.0",\n=======\n "version": "10.28.0-dev.4",\n>>>>>>> theirs\ny\n' assert version_merge.resolve_text(text) == 'x\n "version": "10.28.0",\ny\n' From 20402ed6072d5fe9627f715bd867f99d456b12e1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 07:56:01 +0000 Subject: [PATCH 27/70] =?UTF-8?q?fix(release):=20=E6=9C=AC=E7=95=AA?= =?UTF-8?q?=E3=81=AE=E9=85=8D=E5=B8=83=E3=81=AE=E5=86=8D=E9=96=8B=E3=81=A7?= =?UTF-8?q?=E3=80=81=E6=89=BF=E8=AA=8D=E3=81=97=E3=81=9F=E3=82=B3=E3=83=9F?= =?UTF-8?q?=E3=83=83=E3=83=88=E3=82=92=E5=90=AB=E3=82=80=E3=83=9E=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=81=A8=E5=90=8C=E3=81=98=E3=82=B3=E3=83=9F=E3=83=83?= =?UTF-8?q?=E3=83=88=E3=82=92=E6=8C=87=E3=81=99=E3=82=BF=E3=82=B0=E3=81=A0?= =?UTF-8?q?=E3=81=91=E3=82=92=E6=B8=88=E3=82=93=E3=81=A0=E3=82=82=E3=81=AE?= =?UTF-8?q?=E3=81=A8=E3=81=BF=E3=81=AA=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/release-steps.py | 5 +++++ .../tests/test_release_approved_commit.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/plugins/ndf/scripts/release-steps.py b/plugins/ndf/scripts/release-steps.py index a8f3e11c6..3d20c0f37 100755 --- a/plugins/ndf/scripts/release-steps.py +++ b/plugins/ndf/scripts/release-steps.py @@ -842,6 +842,8 @@ def cmd_release(a): main_pr = done["number"] git(root, "fetch", "-q", "origin") merge = merge_commit_of(root, main_pr) or git(root, "rev-parse", f"origin/{prod}").stdout.strip() + if git(root, "merge-base", "--is-ancestor", approved, merge, check=False).returncode != 0: + raise StepError(f"マージ済みの本番の PR #{main_pr} のコミット {merge[:8]} が承認したコミット {approved[:8]} を含まない") metrics["approved_sha"] = approved items.append({"kind": "pr", "name": f"#{main_pr}", "result": "already_merged", "base": prod}) else: @@ -873,6 +875,9 @@ def _publish_tag_and_release(root, plugin, ver, tag, merge): origin にあるタグと GitHub Release は作り直さない(再開)。items(tag・release の 1 件ずつ)を返す。""" items = [] if git(root, "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/{tag}", check=False).returncode == 0: + git(root, "fetch", "-q", "origin", f"+refs/tags/{tag}:refs/tags/{tag}") + if (at := git(root, "rev-parse", f"refs/tags/{tag}^{{commit}}").stdout.strip()) != merge: + raise StepError(f"origin のタグ {tag} が本番チャネルのコミット {merge[:8]} でなく {at[:8]} を指す") items.append({"kind": "tag", "name": tag, "result": "exists"}) else: if git(root, "rev-parse", "-q", "--verify", f"refs/tags/{tag}", check=False).returncode != 0: diff --git a/plugins/ndf/scripts/tests/test_release_approved_commit.py b/plugins/ndf/scripts/tests/test_release_approved_commit.py index 96501517e..792b96098 100644 --- a/plugins/ndf/scripts/tests/test_release_approved_commit.py +++ b/plugins/ndf/scripts/tests/test_release_approved_commit.py @@ -294,6 +294,24 @@ def test_release_after_the_production_pr_merged_makes_only_what_is_missing(monke assert out["metrics"]["main_pr"] == 2 +def test_release_resume_stops_when_the_tag_points_elsewhere(monkeypatch, world): + """再開でも、origin にあるタグが本番チャネルのコミットを指していなければ作ったものとみなさず止める。""" + git(world["work"], "tag", "-a", f"foo--v{VER}", world["approved"], "-m", "t") + git(world["work"], "push", "-q", "origin", f"foo--v{VER}") + git(world["work"], "tag", "-d", f"foo--v{VER}") + with pytest.raises(Exception) as e: + run_release(monkeypatch, world, prod_merged=True, approved_sha=world["approved"]) + assert getattr(e.value, "code", None) == 1 and f"foo--v{VER}" in str(e.value) + + +def test_release_resume_stops_when_the_merged_pr_lacks_the_approved_commit(monkeypatch, world): + """再開でも、マージ済みの本番の PR が承認したコミットを含まなければ続けない。""" + side = git(world["work"], "commit-tree", "HEAD^{tree}", "-m", "side") # 本番チャネルに無いコミット + with pytest.raises(Exception) as e: + run_release(monkeypatch, world, prod_merged=True, approved_sha=side) + assert getattr(e.value, "code", None) == 1 and side[:8] in str(e.value) + + @pytest.mark.parametrize( "extra, code, text", [ From d6f5021361335aeb43a4000ac3bc8f6ecc72f0db Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 07:57:23 +0000 Subject: [PATCH 28/70] =?UTF-8?q?feat(parallel):=20=E3=83=AC=E3=83=BC?= =?UTF-8?q?=E3=83=B3=E3=81=A8=E4=B8=80=E6=99=82=E3=83=AC=E3=83=BC=E3=83=B3?= =?UTF-8?q?=E3=81=AE=E5=95=8F=E3=81=84=E3=82=92=E3=82=AA=E3=83=BC=E3=82=AC?= =?UTF-8?q?=E3=83=8A=E3=82=A4=E3=82=B6=E3=83=BC=E3=81=B8=E9=80=81=E3=82=8A?= =?UTF-8?q?=E3=80=81=E7=AD=94=E3=81=88=E3=82=92=E6=8C=81=E3=81=A1=E4=B8=BB?= =?UTF-8?q?=E3=81=AE=E3=83=AC=E3=83=BC=E3=83=B3=E3=81=B8=E8=BF=94=E3=81=99?= =?UTF-8?q?=EF=BC=88#2436=20=E3=81=AE=206c=E3=83=BB6d=E3=83=BB6f=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 問いの hook が、動いている計画のレーンか役割のファイルが helper の一時レーンの通す問いを parallel.py request-ask で送り、deny と保留の手順を返す(送れなければ今どおり通す) - 耐久ワークフローが同じ依頼の打ち直しを数えず、answer のメッセージで answer-<依頼> を 1 回だけ立てて問いの一覧から外す。human-action には attach の 1 行を添える - parallel.py に request-ask・request-gate2・answer・helper を足す。helper は一時レーンが流す pipeline(提示か確定の後)を最後の起動の指示から返す - ループのレーンは承認ゲート 1 で設計の承認の依頼を送り、答えは周回の境で読む Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/hook_lib/ask_guard.py | 66 ++++++++++- plugins/ndf/scripts/lib/parallel_lane.py | 111 ++++++++++++++++++ plugins/ndf/scripts/lib/parallel_run.py | 51 +++++++- plugins/ndf/scripts/lib/sprint_loop_gate.py | 22 ++++ plugins/ndf/scripts/parallel.py | 97 +++++++++++++++ plugins/ndf/scripts/tests/test_ask_guard.py | 66 +++++++++++ .../ndf/scripts/tests/test_parallel_run.py | 84 +++++++++++++ plugins/ndf/scripts/tests/test_sprint_loop.py | 11 ++ 8 files changed, 505 insertions(+), 3 deletions(-) diff --git a/plugins/ndf/scripts/hook_lib/ask_guard.py b/plugins/ndf/scripts/hook_lib/ask_guard.py index b832d239b..1a054d034 100644 --- a/plugins/ndf/scripts/hook_lib/ask_guard.py +++ b/plugins/ndf/scripts/hook_lib/ask_guard.py @@ -8,13 +8,23 @@ | `off` | 何も出さず、記録も書かない | 判定のたびに `lib/ask_ledger.py` へ 1 行を追記する。追記の失敗は飲む。どの失敗も None(通す)を返す(I8)。 + +並列の計画(#2436 の 6d): 判定が通す問いを、動いている計画のレーンか一時レーン(作業ディレクトリの役割のファイルが +`helper`)が出したときは、`parallel.py request-ask` でオーガナイザーへ送り、`deny` で止めて保留の手順を返す。 +レーンでない・送れない・上限を越えたときは今どおり通す。 """ from __future__ import annotations +import json +import os +import subprocess +import sys from pathlib import Path ASK_PY = Path(__file__).resolve().parent.parent / "ask.py" +PARALLEL_PY = Path(__file__).resolve().parent.parent / "parallel.py" +FORWARD_TIMEOUT = 8 # 秒。hook の 10 秒の上限の中に収める ASKING_MD = Path(__file__).resolve().parent.parent.parent / "skills" / "development-workflow" / "references" / "asking.md" STOP_REASONS = { @@ -77,6 +87,56 @@ def _record_verdict(ev, verdict, decl) -> str: return row["id"] +def run_request_ask(main: Path, plan: str, lane: str, payload: dict) -> tuple[int, dict]: + """`parallel.py request-ask` を子プロセスで打つ(上限 FORWARD_TIMEOUT 秒)。(終了コード, 統一 JSON レポート)。""" + got = subprocess.run( + [sys.executable, str(PARALLEL_PY), "request-ask", plan, "--lane", lane], + input=json.dumps(payload, ensure_ascii=False), + capture_output=True, + text=True, + cwd=str(main), + timeout=FORWARD_TIMEOUT, + ) + try: + return got.returncode, json.loads(got.stdout.strip().splitlines()[-1]) if got.stdout.strip() else {} + except ValueError: + return got.returncode or 2, {} + + +def hold_text(rid: str, plan: str) -> str: + """オーガナイザーへ送った問いの理由と保留の手順(正本: asking.md の「並列の計画のレーン」)。""" + loop = f"python3 \"{Path(__file__).resolve().parent.parent / 'sprint-state.py'}\" loop hold-question <ループ> --ref {rid} --plan <止まったプラン> --step <続きのステップ> --parallel {plan}" + return "\n".join( + [ + f"ndf: 並列の計画 {plan} のレーンの問いをオーガナイザーへ送った(依頼 {rid})。この作業を保留し、別の作業へ進め。答えは周回の境で届く。", + f"開発ループのレーン: {loop} を打ち、周回を質問待ちで締めて次の周回へ進む。", + f"1 スプリントのレーン・一時レーン: 引継ぎ文書の「次にやること」に続きのコマンドと依頼 {rid} を書き、ndf-next を出さずにセッションを終える(オーガナイザーが起こす)。", + f"正本: {ASKING_MD}", + ] + ) + + +def forward_to_organizer(ev, row_id: str) -> dict | None: + """レーンか一時レーンの通す問いをオーガナイザーへ送り、送れたら deny を返す。当たらない・送れなければ None(通す)。""" + name = os.environ.get("NDF_RELAY_NAME") + if not name: + return None + import decl + import parallel_lane + + main = decl.main_dir(ev.cwd) or Path(ev.cwd) + plan = parallel_lane.lane_name_in_plans(main, name, os.environ.get("NDF_RELAY_DIR")) + if not plan: + return None + code, got = run_request_ask(main, plan, name, {"tool_input": ev.tool_input, "cwd": ev.cwd, "ledger_id": row_id}) + rid = (got.get("metrics") or {}).get("id") + if code != 0 or not rid: + return None + from hook_lib.token_guard import deny + + return deny(hold_text(rid, plan)) + + def _context(text: str) -> dict: return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": text}} @@ -95,6 +155,10 @@ def ask_decision(ev) -> dict | None: return deny(ask_stop_text(verdict, decl, row_id)) if verdict.decision == "advise": return _context("ndf: mode が advise のため止めていない。\n" + ask_stop_text(verdict, decl, row_id)) - return _context(pass_text(verdict, decl)) + try: + held = forward_to_organizer(ev, row_id) + except Exception: # noqa: BLE001 — 送れない・上限を越えたときは今どおり通す(I8) + held = None + return held or _context(pass_text(verdict, decl)) except Exception: # noqa: BLE001 — 宣言・判定・記録のどの失敗も通す(I8) return None diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py index 694bd5daa..5a0256e0c 100644 --- a/plugins/ndf/scripts/lib/parallel_lane.py +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -204,3 +204,114 @@ def answers_of(main: Path, name: str, ids: list[str]) -> dict[str, dict]: dfr = c.get_event(workflow_id(name), f"defer-{i}", timeout_seconds=0) out[i] = {"answer": ans if isinstance(ans, dict) else None, "defer": dfr if isinstance(dfr, dict) else None} return out + + +# ---- 問いの依頼と答え(#2436 の 6c・6d・6f。設計 issue-2436-design-approvals.md) ---- + +ROLE_FILE = "role.json" # relay_lib.hub.ROLE_FILE(統括役の作業ディレクトリの役割のファイル。統括役を import しない) + + +def helper_plan_of(relay_dir: Optional[str]) -> Optional[str]: + """作業ディレクトリ(`NDF_RELAY_DIR`)の役割のファイルが `helper` なら、その計画名。""" + if not relay_dir: + return None + try: + got = json.loads((Path(relay_dir) / ROLE_FILE).read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return got.get("plan") if isinstance(got, dict) and got.get("role") == "helper" and got.get("plan") else None + + +def lane_name_in_plans(main: Path, name: Optional[str], relay_dir: Optional[str]) -> Optional[str]: + """レーンか一時レーンかをファイルだけで見る(問いの hook の 10 秒の上限の中で DBOS を読まない)。計画名か None。""" + if not name: + return None + for plan in plans_in(main): + try: + lanes = json.loads((pp.plan_dir(main, plan) / "plan.json").read_text(encoding="utf-8")).get("lanes") or [] + except (OSError, ValueError, AttributeError): + continue + if any(isinstance(ln, dict) and ln.get("name") == name for ln in lanes): + return plan + return helper_plan_of(relay_dir) + + +def sender_of(main: Path, name: Optional[str], relay_dir: Optional[str], status=run_status) -> Optional[tuple[str, str]]: + """問いを送ってよい送り手か: 動いている計画のレーン(`lane_of`)か、役割のファイルが `helper` で、その計画の + 耐久ワークフローが終わっていない一時レーン。(計画名, 送り手の名前) か None。""" + got = lane_of(main, name, status) if name else None + if got: + return got[0], name + plan = helper_plan_of(relay_dir) + if name and plan and status(main, plan) in RUNNING: + return plan, name + return None + + +def _now() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +def _send_inbox(main: Path, name: str, msg: dict, key: str) -> None: + import durable + + wid = workflow_id(name) + durable.send_to(KIND, record_identity(main, name), wid, msg, "inbox", f"{wid}:inbox:{key}") + + +def _head(cwd: str) -> str: + got = subprocess.run(["git", "-C", cwd, "rev-parse", "HEAD"], capture_output=True, text=True) + return got.stdout.strip() if got.returncode == 0 else "" + + +def request_ask(main: Path, name: str, lane: str, tool_input: dict, cwd: str, ledger_id: str) -> str: + """承認ゲートの外の区分つきの問い(`kind: ask`)をオーガナイザーへ送る。id は `<送り手>-q<判定の記録の id>`。""" + qs = (tool_input or {}).get("questions") or [] + first = qs[0] if qs and isinstance(qs[0], dict) else {} + rid = f"{lane}-q{ledger_id}" + msg = { + "kind": "ask", + "id": rid, + "lane": lane, + "source": ((tool_input or {}).get("metadata") or {}).get("source"), + "question": first.get("question"), + "options": [o.get("label") for o in first.get("options") or [] if isinstance(o, dict)], + "questions": qs, + "cwd": cwd, + "ledger_id": ledger_id, + "stamp": _head(cwd), + "at": _now(), + } + _send_inbox(main, name, msg, rid) + return rid + + +def request_gate1(main: Path, name: str, lane: str, issue: int, pr: int, report: str, state: str, stamp: str, **extra) -> str: + """設計の承認の依頼(`kind: gate1`)。id は `<レーン>-g<設計 PR>-<先頭の SHA の 12 桁>`(同じ先頭の打ち直しは同じ id)。""" + rid = f"{lane}-g{pr}-{stamp[:12]}" + msg = {"kind": "gate1", "id": rid, "lane": lane, "issue": issue, "pr": pr, "report": report, "sprint_state": state, "stamp": stamp} + _send_inbox(main, name, {**msg, **extra, "at": _now()}, rid) + return rid + + +def request_gate2(main: Path, name: str, n: int, k: int, report: str) -> str: + """一時レーンが提示のプランの承認ゲート 2 で止まったときの依頼(`kind: gate2`)。id は `<計画名>-p<連番>-<提示>`、 + stamp は提示したコミット(`presented.json`)。""" + import parallel_release as prl + + try: + sha = json.loads((prl.release_dir(main, name, n, k) / "presented.json").read_text(encoding="utf-8")).get("sha") or "" + except (OSError, ValueError): + sha = "" + rid = f"{name}-p{n}-{k}" + lane = prl.helper_name(name, n) + _send_inbox(main, name, {"kind": "gate2", "id": rid, "lane": lane, "n": n, "k": k, "report": report, "stamp": sha, "at": _now()}, rid) + return rid + + +def send_answer( + main: Path, name: str, rid: str, result: str, note: Optional[str] = None, choice: Optional[list[str]] = None, stamp: Optional[str] = None +) -> None: + """オーガナイザーの答えを送る。耐久ワークフローが `answer-<依頼>` を 1 回だけ立て、問いの一覧から外す。""" + msg = {"kind": "answer", "id": rid, "result": result, "note": note, "choice": list(choice or []), "stamp": stamp, "at": _now()} + _send_inbox(main, name, msg, f"answer:{rid}") diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index b79491538..6ae3e4914 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -3,7 +3,7 @@ 耐久ワークフローの中の分岐は耐久ステップの結果とメッセージだけで決まる。時刻・外のコマンド・ファイルを読むのは すべて耐久ステップである(打ち直しで同じ道を通るため。#1142 の I18)。外へ見せるのはイベントと出力だけである。 -- メッセージ `inbox`: `{kind: request | gate1 | gate2 | ask | stop, ...}`(レーン・一時レーン・`stop`) +- メッセージ `inbox`: `{kind: request | gate1 | gate2 | ask | answer | stop, ...}`(レーン・一時レーン・オーガナイザーの答え・`stop`) - メッセージ `resume`: `{n, phase: confirm, take}` / `{n, phase: settle, result, version, reason}`(`confirm`・`settle`) - イベント `pause`: `{seq, action}`(`running` / `notice` / `release` / `confirmed`。配布の途中の `notice` は 最後の起動の指示を `release` に添える)、`questions`: `{seq, items}` @@ -204,6 +204,7 @@ def __init__(self, main: str, name: str, lanes: list[dict]): self.seq = 0 self.launch: Optional[dict] = None self.received: dict[str, int] = {} + self.answered: set[str] = set() def bump(self) -> int: self.seq += 1 @@ -242,8 +243,27 @@ def take(self, msg) -> None: self.received[msg.get("lane", "")] = self.received.get(msg.get("lane", ""), 0) + 1 durable.set_event(f"lane-{msg.get('lane')}", {"received": self.received[msg.get("lane", "")]}) elif kind in ("gate1", "gate2", "ask"): + if any(q.get("id") == msg.get("id") for q in self.questions) or msg.get("id") in self.answered: + return # 同じ依頼の打ち直しは数えない(id は送る側が依頼の中身から決める) + if msg.get("source") == "ndf-ask:human-action": # 代われない操作: レーンへ入る 1 行を添える(6d) + msg = {**msg, "attach": f"python3 {RELAY} attach {msg.get('lane')}"} self.questions.append(msg) - durable.set_event("questions", {"seq": self.bump(), "items": self.questions}) + self.publish_questions() + elif kind == "answer": + self.reply(msg) + + def publish_questions(self) -> None: + durable.set_event("questions", {"seq": self.bump(), "items": self.questions}) + + def reply(self, msg: dict) -> None: + """オーガナイザーの答えを `answer-<依頼>` に 1 回だけ立て、問いの一覧から外す。答えの後の処理は持ち主のレーンが行う(6f)。""" + rid = msg.get("id") + if rid in self.answered or all(q.get("id") != rid for q in self.questions): + return + self.answered.add(rid) + durable.set_event(f"answer-{rid}", {k: msg.get(k) for k in ("result", "note", "choice", "stamp", "at")}) + self.questions = [q for q in self.questions if q.get("id") != rid] + self.publish_questions() def answer(self, rid: str, result: str, version: Optional[str], n: Optional[int], reason: Optional[str] = None) -> None: durable.set_event(f"answer-{rid}", {"result": result, "version": version, "release": n, "reason": reason, "at": None}) @@ -295,6 +315,9 @@ def resume(self, msg: dict) -> None: rel = self.current if rel is None or msg.get("n") != rel["n"]: return + if any(q.get("kind") == "gate2" and q.get("n") == rel["n"] for q in self.questions): # 承認ゲート 2 の答えが届いた + self.questions = [q for q in self.questions if not (q.get("kind") == "gate2" and q.get("n") == rel["n"])] + self.publish_questions() if msg.get("phase") == "confirm": self.confirm(rel, msg.get("take")) elif msg.get("phase") == "settle": @@ -356,6 +379,30 @@ def helper_spawn(name: str, n: int, k: int) -> str: return f"python3 {RELAY} spawn --name {pr.helper_name(name, n)} --role helper --plan {name} --json -- '{first}'" +def helper_task(main: Path, name: str, n: int, k: int) -> dict: + """一時レーン(`--parallel-helper <計画名> <連番> <提示>`)が流すもの。最後の起動の指示(イベント `pause` か、 + 配布の途中の知らせに添えた `release`)が同じ連番と提示の `confirmed` なら確定の後の 3 手、そうでなければ提示。 + + - 提示(`phase: present`): `pipeline` は提示のプラン → 本番のプランの queue と待ち。終われば提示のプランが承認ゲート 2 で + 止まったかを見て、止まっていれば `gate2`(承認ゲート 2 の依頼を送る)、流れ切れば `settle` を打つ + - 確定の後(`phase: confirmed`): `pipeline` は答えの記録 → 提示のプランを `approve` から → 本番のプランの queue と待ち。 + 終われば `settle` を打つ。`approve` が承認ゲート(10)で終われば queue を打たずに問いとして送る""" + ev = durable.event_from(pl.KIND, pl.record_identity(main, name), pl.workflow_id(name), "pause", 0) or {} + launch = ev if ev.get("action") in ("release", "confirmed") else ev.get("release") or {} + d = pr.release_dir(main, name, n, k) + py = f"python3 {HERE / 'parallel.py'}" + out = {"n": n, "k": k, "dir": str(d), "gate2": f"{py} request-gate2 {name} {n} {k} --report {d / 'present-state' / 'report.md'}"} + if launch.get("action") == "confirmed" and launch.get("n") == n and launch.get("k") == k: + done = d / "release-done.json" + gate = f"python3 {HERE / 'sprint-state.py'} gate {d / 'answer.json'} '関門 2' --what 'まとめた配布 {n} の提示 {k}' --by user --outcome approved" + steps = [gate, f"{SUPERVISE} run {d / 'present.json'} --from approve", f"{SUPERVISE} queue {d / 'release.json'} --done {done}"] + return {**out, "phase": "confirmed", "pipeline": " && ".join([*steps, f"{SUPERVISE} wait {done}"]), + "settle": f"{py} settle {name} {n} --done {done}"} # fmt: skip + done = d / "done.json" + return {**out, "phase": "present", "pipeline": f"{pr.pipeline_cmd(SUPERVISE, d)} && {SUPERVISE} wait {done}", + "settle": f"{py} settle {name} {n} --done {done}"} # fmt: skip + + @durable.workflow(name="parallel") def parallel_workflow(main: str, name: str) -> dict: init = step_init(main, name) diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index 39fc2b7fb..41be6195f 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -356,6 +356,8 @@ def move_to_waiting( "number": int(n), "reason": "/".join(reasons) or row.get("verdict", "判定を読めない"), "verdict": row.get("verdict", "unknown"), "basis": row.get("basis") or [], "gate": 1, "pr": pr, "lap": lap["n"], "at": env.now(), } # fmt: skip + if pr is not None and (sent := lane_gate1(env, w, i.get("report") or "", lap["state_path"])): + w.update(sent) # 並列の計画のレーン: 設計の承認をオーガナイザーへ依頼し、答えは周回の境で読む(#2436 の 6c) rec["waiting"].append(w) lap.setdefault("gates", []).append(gate_copy(GATE_1, row)) moved.append(int(n)) @@ -366,6 +368,26 @@ def move_to_waiting( return moved, kept, notes +def lane_gate1(env: Env, w: dict, report: str, state: str) -> dict | None: + """並列の計画のレーンなら、設計の承認の依頼(`kind: gate1`)をオーガナイザーへ送り、{request, plan} を返す。 + レーンでない・送れなければ None(今どおり人の判断待ちとして残す)。印は設計 PR の先頭の SHA(問いの時点の印)。""" + try: + import parallel_lane + + got = parallel_lane.lane_of(env.main_dir()) + if not got: + return None + code, so, _ = env.run(["gh", "pr", "view", str(w["pr"]), "--json", "headRefOid", "-q", ".headRefOid"], cwd=str(env.main_dir())) + stamp = so.strip() if code == 0 else "" + rid = parallel_lane.request_gate1( + env.main_dir(), got[0], got[1].name, w["number"], int(w["pr"]), report, state, stamp or "0" * 12, + verdict=w.get("verdict"), reasons=[w.get("reason")] if w.get("reason") else [], + ) # fmt: skip + except Exception: # noqa: BLE001 — 送れなければ今どおり人の判断待ち(loop answer で答える) + return None + return {"request": rid, "plan": got[0]} + + def drop_issues(plan: str, moved: list[int]) -> None: """残りの課題でも流す共通の計画(検査など)から、人の判断待ちへ移した課題を外して書き戻す。 計画の `課題` と、pr のステップの材料の `closes`(スプリント PR の Closes の行)の両方から外す。""" diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index 0e6500778..150783ce8 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -14,6 +14,10 @@ | `stop <計画名>` | 計画の全レーンを止め、耐久ワークフローを終える | | `request <計画名> --lane <名> --state <パス> [--prs ...] [--issues ...]` | 配布の依頼を送る(10)か、すでに `main` にあれば送らない(0) | | `await <計画名> --from <パス> [--timeout 秒]` | 依頼の答えを読む(released 0・rejected 10・failed 1・まだ無い 3) | +| `request-ask <計画名> --lane <名>` | 標準入力の問い(`tool_input`・`cwd`・`ledger_id`)をオーガナイザーへ送る(送り手でなければ 4) | +| `request-gate2 <計画名> <連番> <提示> --report <承認資料>` | 一時レーンが承認ゲート 2 の依頼を送る | +| `answer <計画名> <依頼> (--approved \\| --revise --note <パス> \\| --rejected \\| --choice <答え>...)` | 問いの答えを送る(持ち主のレーンが止まっていれば `metrics.next` に起こす 1 行) | +| `helper <計画名> <連番> <提示>` | 一時レーンが流すもの(`phase`・`pipeline`・`settle`・`gate2`)を返す | 置き場はメインディレクトリの `.ndf/parallel/<計画名>/`(コミットしない)。正本の手順は `skills/development-workflow/references/parallel.md` と `skills/issue-plan-strategy/references/parallel-plan.md` にある。 @@ -336,6 +340,74 @@ def cmd_await(a) -> None: ) +# ---- 問いと一時レーン(#2436 の 6c・6d・6f) ---- + + +def cmd_request_ask(a) -> None: + main = main_of() + try: + got = json.loads(sys.stdin.read() or "{}") + except ValueError as exc: + reply("stopped", f"標準入力の問いを読めない: {exc}", code=2) + who = pl.sender_of(main, a.lane, os.environ.get("NDF_RELAY_DIR")) + if who is None: + reply("stopped", f"{a.lane} は動いている計画のレーンでも一時レーンでもない(問いは通す)", code=4) + if who[0] != a.name: + reply("stopped", f"{a.lane} は計画 {who[0]} の送り手で、{a.name} の送り手でない", code=4) + rid = pl.request_ask(main, a.name, a.lane, got.get("tool_input") or {}, got.get("cwd") or os.getcwd(), str(got.get("ledger_id") or "")) + reply("ok", f"問いの依頼 {rid} をオーガナイザーへ送った", id=rid) + + +def cmd_request_gate2(a) -> None: + main = main_of() + if pl.run_status(main, a.name) not in pl.RUNNING: + reply("stopped", f"並列の計画 {a.name} が動いていない", code=1) + rid = pl.request_gate2(main, a.name, a.n, a.k, a.report) + reply("ok", f"承認ゲート 2 の依頼 {rid} をオーガナイザーへ送った。答えを待つ間は止まる", id=rid) + + +def _owner_restart(main: Path, name: str, rid: str) -> str | None: + """答えた依頼の持ち主のレーンが止まっていれば、起こす 1 行(`relay.py restart <レーン>`)。動いていれば None。""" + import durable + import parallel_run as pr_run + + items = (durable.event_from(pl.KIND, pl.record_identity(main, name), pl.workflow_id(name), "questions", 0) or {}).get("items") or [] + lane = next((q.get("lane") for q in items if q.get("id") == rid), None) + if not lane: + return None + code, got = pr_run.relay(main, "ls", "--json") + state = next((i.get("state") for i in got.get("items") or [] if i.get("name") == lane), None) if code == 0 else None + return None if state in ("running", "awaiting_approval") else f"python3 {pr_run.RELAY} restart {lane}" + + +def cmd_answer(a) -> None: + main = main_of() + if pl.run_status(main, a.name) not in pl.RUNNING: + reply("stopped", f"並列の計画 {a.name} が動いていない", code=1) + note = None + if a.revise: + if not a.note: + reply("stopped", "--revise には --note <指摘のファイル> が要る", code=1) + try: + note = Path(a.note).read_text(encoding="utf-8").strip() + except OSError as exc: + reply("stopped", f"指摘のファイルを読めない: {exc}", code=2) + result = "approved" if a.approved else "revise" if a.revise else "rejected" if a.rejected else "answered" + nxt = _owner_restart(main, a.name, a.id) + pl.send_answer(main, a.name, a.id, result, note=note, choice=a.choice) + reply("ok", f"依頼 {a.id} へ答え({result})を送った", id=a.id, result=result, next=nxt) + + +def cmd_helper(a) -> None: + import parallel_run as pr_run + + main = main_of() + if pl.run_status(main, a.name) not in pl.RUNNING: + reply("stopped", f"並列の計画 {a.name} が動いていない", code=1) + task = pr_run.helper_task(main, a.name, a.n, a.k) + reply("ok", f"一時レーンの仕事: 配布 {a.n} の提示 {a.k}({task['phase']})", **task) + + def build_parser() -> argparse.ArgumentParser: ap = argparse.ArgumentParser(prog="parallel.py", description=__doc__.splitlines()[0]) sub = ap.add_subparsers(dest="cmd", required=True) @@ -379,6 +451,27 @@ def build_parser() -> argparse.ArgumentParser: aw.add_argument("name") aw.add_argument("--from", required=True) aw.add_argument("--timeout", type=float, default=0) + ra = sub.add_parser("request-ask") + ra.add_argument("name") + ra.add_argument("--lane", required=True) + rg = sub.add_parser("request-gate2") + rg.add_argument("name") + rg.add_argument("n", type=int) + rg.add_argument("k", type=int) + rg.add_argument("--report", required=True) + an = sub.add_parser("answer") + an.add_argument("name") + an.add_argument("id") + ag = an.add_mutually_exclusive_group(required=True) + ag.add_argument("--approved", action="store_true") + ag.add_argument("--revise", action="store_true") + ag.add_argument("--rejected", action="store_true") + ag.add_argument("--choice", nargs="+") + an.add_argument("--note") + hp = sub.add_parser("helper") + hp.add_argument("name") + hp.add_argument("n", type=int) + hp.add_argument("k", type=int) return ap @@ -396,6 +489,10 @@ def main(argv=None) -> None: "stop": cmd_stop, "request": cmd_request, "await": cmd_await, + "request-ask": cmd_request_ask, + "request-gate2": cmd_request_gate2, + "answer": cmd_answer, + "helper": cmd_helper, }[a.cmd](a) diff --git a/plugins/ndf/scripts/tests/test_ask_guard.py b/plugins/ndf/scripts/tests/test_ask_guard.py index f6de2f01e..c2de49bfd 100644 --- a/plugins/ndf/scripts/tests/test_ask_guard.py +++ b/plugins/ndf/scripts/tests/test_ask_guard.py @@ -313,3 +313,69 @@ def test_summary_counts(proj): assert m["verdict"] == {"deny": 2, "allow": 1, "advise": 0} assert m["source"] == {"": 2, "ndf-gate:1": 1} assert m["decision"] == {"ref": 1, "no_ref": 1} and m["unanswered"] == 1 + + +# ---------------------------------------------------------------- #2436 の 6d: 並列の計画のレーンの問いをオーガナイザーへ送る + + +def lane_plan(proj, lanes=("la",)): + d = proj / ".ndf" / "parallel" / "p1" + d.mkdir(parents=True) + (d / "plan.json").write_text(json.dumps({"version": 1, "name": "p1", "rules": [], "lanes": [{"name": n} for n in lanes]})) + + +@pytest.fixture() +def forwarded(monkeypatch): + from hook_lib import ask_guard + + box = {"code": 0, "calls": []} + + def fake(main, plan, lane, payload): + box["calls"].append((plan, lane, payload)) + return box["code"], {"metrics": {"id": f"{lane}-q{payload['ledger_id']}"}} + + monkeypatch.setattr(ask_guard, "run_request_ask", fake) + return box + + +def decide(proj, source): + from hook_lib import ask_guard, payload + + return ask_guard.ask_decision(payload.event_of(ask_input(proj, source))) + + +def test_a_lane_question_is_sent_to_the_organizer_and_denied(proj, monkeypatch, forwarded): + lane_plan(proj) + monkeypatch.setenv("NDF_RELAY_NAME", "la") + out = decide(proj, "ndf-ask:cost") + assert is_deny(out) and "オーガナイザーへ送った" in json.dumps(out, ensure_ascii=False) + plan, lane, sent = forwarded["calls"][0] + assert (plan, lane) == ("p1", "la") and sent["tool_input"]["metadata"]["source"] == "ndf-ask:cost" and sent["ledger_id"] + + +def test_a_helper_question_is_sent_by_the_role_file(proj, monkeypatch, forwarded, tmp_path): + lane_plan(proj) + relay = tmp_path / "relay" + relay.mkdir() + (relay / "role.json").write_text(json.dumps({"role": "helper", "plan": "p1"})) + monkeypatch.setenv("NDF_RELAY_NAME", "p1-rel1") + monkeypatch.setenv("NDF_RELAY_DIR", str(relay)) + assert is_deny(decide(proj, "ndf-ask:human-action")) and forwarded["calls"][0][:2] == ("p1", "p1-rel1") + + +@pytest.mark.parametrize("name, code", [(None, 0), ("zz", 0), ("la", 4), ("la", 1)]) +def test_not_a_lane_or_not_sent_passes_as_before(proj, monkeypatch, forwarded, name, code): + lane_plan(proj) + monkeypatch.delenv("NDF_RELAY_DIR", raising=False) + if name: + monkeypatch.setenv("NDF_RELAY_NAME", name) + else: + monkeypatch.delenv("NDF_RELAY_NAME", raising=False) + forwarded["code"] = code + assert not is_deny(decide(proj, "ndf-ask:cost")) + + +def test_a_denied_question_is_not_sent(proj, monkeypatch, forwarded): + lane_plan(proj) + monkeypatch.setenv("NDF_RELAY_NAME", "la") + assert is_deny(decide(proj, None)) and forwarded["calls"] == [] diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index b4afad89b..0ef47f8bb 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -338,3 +338,87 @@ def test_notice_during_release_carries_launch(env, tmp_path): ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "notice" and e) assert ev["release"]["seq"] == rel["seq"] and ev["release"]["spawn"] == rel["spawn"] assert ev["release"]["pipeline"] == rel["pipeline"] + + +# ---- 問いをオーガナイザーへ集める(受け入れ条件 6c・6d・6f)と一時レーン ---- + +ASK = {"questions": [{"question": "gh の認証を直してよいか", "options": [{"label": "済んだ"}, {"label": "まだ"}]}], + "metadata": {"source": "ndf-ask:human-action"}} # fmt: skip + + +def questions(main): + return event(main, "p1", "questions") or {"items": []} + + +def test_ask_request_raises_questions_and_the_answer_reaches_the_lane(env, tmp_path): + main = env["main"] + start(main) + rid = pl.request_ask(main, "p1", "la", ASK, str(tmp_path), "led1") + assert rid == "la-qled1" + got = until(lambda: [i for i in questions(main)["items"] if i["id"] == rid]) + assert got[0]["kind"] == "ask" and got[0]["source"] == "ndf-ask:human-action" and "attach la" in got[0]["attach"] + pl.send_answer(main, "p1", rid, "answered", choice=["済んだ"]) + ans = until(lambda: pl.answers_of(main, "p1", [rid])[rid]["answer"]) + assert ans["result"] == "answered" and ans["choice"] == ["済んだ"] + until(lambda: not [i for i in questions(main)["items"] if i["id"] == rid]) # 答えた問いはもう返さない + + +def test_the_same_ask_id_is_counted_once(env, tmp_path): + main = env["main"] + start(main) + pl.request_ask(main, "p1", "la", ASK, str(tmp_path), "led1") + pl.request_ask(main, "p1", "la", ASK, str(tmp_path), "led1") + until(lambda: questions(main)["items"]) + time.sleep(0.6) + assert len(questions(main)["items"]) == 1 + + +def test_sender_is_a_running_lane_or_a_helper_with_the_role_file(env, tmp_path): + main = env["main"] + helper = tmp_path / "helper" + helper.mkdir() + (helper / "role.json").write_text(json.dumps({"role": "helper", "plan": "p1"})) + other = tmp_path / "other" + other.mkdir() + assert pl.lane_name_in_plans(main, "la", None) == "p1" # ファイルだけで見る + assert pl.lane_name_in_plans(main, "p1-rel1", str(helper)) == "p1" + assert pl.lane_name_in_plans(main, "zz", str(other)) is None + assert pl.sender_of(main, "la", None) is None # 計画が動いていない + start(main) + until(lambda: pl.run_status(main, "p1") in pl.RUNNING) + assert pl.sender_of(main, "la", None) == ("p1", "la") + assert pl.sender_of(main, "p1-rel1", str(helper)) == ("p1", "p1-rel1") # lanes に載らない一時レーンも送れる + assert pl.sender_of(main, "zz", str(other)) is None + + +def test_helper_task_follows_the_last_launch(env, tmp_path): + main = env["main"] + start(main) + pl.send_release_request(main, "p1", "la", [10], [1], tmp_path / "st") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release") + task = prun.helper_task(main, "p1", 1, 1) + d = prl.release_dir(main, "p1", 1, 1) + assert task["phase"] == "present" and f"queue {d / 'present.json'}" in task["pipeline"] + assert "request-gate2 p1 1 1" in task["gate2"] and "settle p1 1 --done" in task["settle"] + (d / "presented.json").write_text(json.dumps({"sha": "sha1", "prs": [10]})) + pl.request_gate2(main, "p1", 1, 1, str(d / "report.md")) + item = until(lambda: [i for i in questions(main)["items"] if i["kind"] == "gate2"])[0] + assert item["id"] == "p1-p1-1" and item["stamp"] == "sha1" + resume(main, {"n": 1, "phase": "confirm"}, "c1") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "confirmed") + until(lambda: not [i for i in questions(main)["items"] if i["kind"] == "gate2"]) # 承認ゲート 2 の問いは答えで消える + task = prun.helper_task(main, "p1", 1, 1) + assert task["phase"] == "confirmed" + assert "'関門 2'" in task["pipeline"] and "--from approve" in task["pipeline"] and f"queue {d / 'release.json'}" in task["pipeline"] + + +def test_request_ask_cli_refuses_a_sender_outside_the_plan_with_4(env, monkeypatch): + monkeypatch.delenv("NDF_RELAY_DIR", raising=False) + got = subprocess.run( + [sys.executable, str(SCRIPTS / "parallel.py"), "request-ask", "p1", "--lane", "zz"], + input=json.dumps({"tool_input": ASK, "cwd": str(env["main"]), "ledger_id": "x"}), + capture_output=True, + text=True, + cwd=str(env["main"]), + ) + assert got.returncode == 4 and json.loads(got.stdout)["status"] == "stopped" diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index aa156679c..3990eaaa7 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -1467,3 +1467,14 @@ def test_gate1_answers_are_applied_by_the_lane_at_the_boundary(env): w = rec(env)["waiting"][0] assert w["answer"] == "approved" and w["pr"] == 501 assert any(c[:3] == ["gh", "pr", "edit"] and "design-approved" in c for c in env.calls) + + +def test_gate_1_of_a_lane_sends_the_request_to_the_organizer(env, monkeypatch): + lap = built_lap(env) + report = design_report(env, "design-11", "https://github.com/o/r/pull/501") + sent = [] + monkeypatch.setattr(sl_gate, "lane_gate1", lambda e, w, report, state: sent.append(w["number"]) or {"request": "lroot-g501-x", "plan": "p1"}) + item = {"plan": str(Path(lap["out"]) / "design-11.json"), "result": "関門", "report": report} + out = sl_gate.cmd_gate(env, "l1", write_done(env, [item])) + w = rec(env)["waiting"][0] + assert out["metrics"]["waiting"] == [11] and sent == [11] and w["request"] == "lroot-g501-x" and w["plan"] == "p1" From 02a3392af2558f17ea922b2e7e44504a24516446 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 07:58:15 +0000 Subject: [PATCH 29/70] =?UTF-8?q?feat(merged):=20=E5=BE=8C=E7=89=87?= =?UTF-8?q?=E4=BB=98=E3=81=91=E3=81=A8=E4=B8=BB=E3=83=87=E3=82=A3=E3=83=AC?= =?UTF-8?q?=E3=82=AF=E3=83=88=E3=83=AA=E3=81=AE=E5=8F=96=E3=82=8A=E8=BE=BC?= =?UTF-8?q?=E3=81=BF=E3=82=92=E4=B8=BB=E3=83=87=E3=82=A3=E3=83=AC=E3=82=AF?= =?UTF-8?q?=E3=83=88=E3=83=AA=E3=81=AE=E6=8E=92=E4=BB=96=E3=81=A7=201=20?= =?UTF-8?q?=E6=9C=AC=E3=81=9A=E3=81=A4=E3=81=AB=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?#2436=20=E3=81=AE=E7=95=B0=E5=B8=B8=E7=B3=BB=208=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/merged-steps.py | 26 ++++++++++++++--- .../ndf/scripts/tests/test_step_scripts.py | 29 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/plugins/ndf/scripts/merged-steps.py b/plugins/ndf/scripts/merged-steps.py index 1866c49cb..2fdb1e342 100755 --- a/plugins/ndf/scripts/merged-steps.py +++ b/plugins/ndf/scripts/merged-steps.py @@ -11,7 +11,8 @@ cleanup: マージ済みの PR の作業ツリーとローカルブランチを外し、主ディレクトリを取り込む。一時の置き場のレビュー worktree (`pr`・`rf`)は lib/run_state.py が `--force` を使わずに外し、続けて持ち主の終わった残る状態を片付ける(`run_state.sweep_states`。 items に足し、metrics の swept_removed・swept_kept で数える。#2229)。開発の作業ツリーで `git worktree remove` が拒否したものは、未追跡・無視されたファイルを退避してから外す。作り直せる生成物 -(`.venv`・`node_modules`・`__pycache__`・`target` など)は退避せずに捨てる(merged_lib/trash.py)。 +(`.venv`・`node_modules`・`__pycache__`・`target` など)は退避せずに捨てる(merged_lib/trash.py)。PR 1 件の片付けと主ディレクトリの +取り込みは、主ディレクトリの排他(`/ndf-main.lock`)を取ってから行う(並列のレーンの間で 1 本ずつ。#2436)。 sweep-trash: 本番に出たコミット(--ref)に含まれるブランチの退避先を回収の候補として挙げる。消すのは、人が候補を見て 承認した後に --yes と --only <承認した退避先>... を付けたときの、その名前の退避先だけ(消すと戻せない利用者のファイル)。 merge-when-green: PR が draft なら `gh pr ready` で外し、CI のチェックが全部通るまで待ち(push で先頭のコミットが @@ -45,6 +46,7 @@ from __future__ import annotations import argparse +import contextlib import json import sys import time @@ -53,7 +55,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent / "lib")) import deps # noqa: E402 -deps.require("waits", "durable", "md", "procs") +deps.require("waits", "durable", "md", "procs", "locks") from step_result import ( StepError, approval_present, @@ -67,6 +69,7 @@ run, ) import gh_parts # noqa: E402 +import locks # noqa: E402 import repo # noqa: E402 import run_owner # noqa: E402 import run_state # noqa: E402 @@ -81,6 +84,19 @@ from merged_lib.reading import read_checks # noqa: E402 TOOL = "merged" +MAIN_LOCK_WAIT = 600.0 # メインディレクトリの排他を待つ上限(秒)。後片付け 1 件と pull は数十秒で終わる + + +@contextlib.contextmanager +def main_dir_lock(main_dir): + """メインディレクトリの index か HEAD を動かす操作を、レーンの間で 1 本ずつにする(`/ndf-main.lock`。 + #2436 の異常系 8)。取れなければ StepError(1)。""" + common = git(main_dir, "rev-parse", "--path-format=absolute", "--git-common-dir").stdout.strip() + try: + with locks.exclusive(Path(common) / "ndf-main", timeout=MAIN_LOCK_WAIT): + yield + except locks.LockTimeout as e: + raise StepError(f"メインディレクトリの排他を取れない: {e}") from None def list_worktrees(root): @@ -282,7 +298,8 @@ def cleanup(root, prs): wt_base = worktree_base.worktree_parent() for n in prs: - _cleanup_pr(root, main_dir, slug, wt_base, n, add) + with main_dir_lock(main_dir): + _cleanup_pr(root, main_dir, slug, wt_base, n, add) # 持ち主の終わった残る状態(merged を通らずに閉じた PR の worktree・一時ファイル)も片付ける(#2229) # 上で扱った pr・rf は見直さない(同じものを 2 度数えない) @@ -290,7 +307,8 @@ def cleanup(root, prs): swept = run_state.sweep_states(root, skip=done) items.extend({**i, "swept": True} for i in swept) git(root, "worktree", "prune", check=False) - pull_err = _update_main_dir(main_dir, add) + with main_dir_lock(main_dir): + pull_err = _update_main_dir(main_dir, add) return _cleanup_result(items, prs, pull_err) diff --git a/plugins/ndf/scripts/tests/test_step_scripts.py b/plugins/ndf/scripts/tests/test_step_scripts.py index cfe238d9b..be7bb550c 100644 --- a/plugins/ndf/scripts/tests/test_step_scripts.py +++ b/plugins/ndf/scripts/tests/test_step_scripts.py @@ -142,6 +142,35 @@ def test_cleanup_all_merged_is_ok(repo, env, tmp_path): assert out["metrics"]["removed_worktrees"] == 1 and out["metrics"]["deleted_branches"] == 1 +def test_cleanup_waits_for_the_main_directory_lock(repo, env, tmp_path): + """#2436 の異常系 8: 別のレーンがメインディレクトリの排他(.git/ndf-main.lock)を持つ間は、後片付けは待ってから進む。""" + import threading + import time + + sys.path.insert(0, str(SCRIPTS / "lib")) + import locks + + write(repo, ".ndf/worktree.json", json.dumps({"base_branch": "main"})) + git(repo, "worktree", "add", "-q", "-b", "feature/x", str(tmp_path / "wx")) + env["FAKE_GH_PRS"] = json.dumps({"1": {"headRefName": "feature/x", "state": "MERGED"}}) + held, hold = threading.Event(), 3.0 + + def holder(): + with locks.exclusive(repo / ".git" / "ndf-main"): + held.set() + time.sleep(hold) + + t = threading.Thread(target=holder) + t.start() + held.wait(5) + start = time.monotonic() + code, out, err = call("merged-steps.py", ["cleanup", "1", "--root", str(repo)], env, proc=True) + waited = time.monotonic() - start + t.join() + assert code == 0, err + assert waited >= hold - 0.5 and out["metrics"]["deleted_branches"] == 1 + + def test_cleanup_from_inside_the_worktree_it_removes(repo, env, tmp_path): # 計画の merge のステップは作業場所(消す作業ツリー)で打つ。消した後も主ディレクトリから続ける write(repo, ".ndf/worktree.json", json.dumps({"base_branch": "main"})) From 75e1b96958092837be348fe48439ecd3f30a6db5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 07:59:32 +0000 Subject: [PATCH 30/70] =?UTF-8?q?docs(release):=20=E6=9C=AC=E7=95=AA?= =?UTF-8?q?=E3=81=AE=20Pull=20Request=20=E3=82=92=20release/v<=E7=89=88>?= =?UTF-8?q?=20=E3=81=8B=E3=82=89=E5=87=BA=E3=81=99=E4=BB=8A=E3=81=AE?= =?UTF-8?q?=E6=B1=BA=E3=81=BE=E3=82=8A=E3=82=92=E6=9B=B8=E3=81=8F=EF=BC=88?= =?UTF-8?q?#2436=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md の Git 運用ルール・版と配布・release の references を、head が release/v<版>・照合の相手が release/v<版>・版数の衝突は -sync で本番の版を取る・マージ済みの再開、の決まりに合わせた。 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +-- docs/versioning-and-distribution.md | 33 ++++++++++++++----- .../release/references/release-steps.md | 17 +++++++--- 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e4517f7c0..0bae76b67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,9 @@ 宛先のチェックがこの設定を読む(`follow_branch: true` のときはメインディレクトリの追従先にも なる)。設定が無いリポジトリは既定ブランチのまま動く - **`--base develop` の付け忘れは継続的統合が塞ぐ。** `main` 宛の Pull Request は - `develop` から出たものだけを通す(`scripts/check-pr-base.sh`)。判定は設定に起点が - 書かれていて、そのブランチが origin にあるときだけ働く + `develop` に含まれるコミットだけを通す(head は `develop` か、先頭が `origin/develop` の祖先の + `release/v<版>`。`scripts/check-pr-base.sh`)。判定は設定に起点が書かれていて、そのブランチが + origin にあるときだけ働く - **開発の変更は `.worktrees/<ブランチ名>` の worktree の中で行う**(`/ndf:worktree`)。clone したディレクトリ(メインディレクトリ)は編集対象から外す - `issues/` `docs/` と各ランタイムの設定はメインディレクトリで編集してよい - メインディレクトリの編集は拒否されない。案内が出ても操作は成立する diff --git a/docs/versioning-and-distribution.md b/docs/versioning-and-distribution.md index b70fd7aba..f743e0bb5 100644 --- a/docs/versioning-and-distribution.md +++ b/docs/versioning-and-distribution.md @@ -7,8 +7,8 @@ NDF プラグインの版数の付け方と、正式版・開発版のリリー ## チャネルと ref **配布のチャネルは 2 つに分ける。`main` が正式版、`develop` が開発版である。** -開発の変更は `develop` へマージし、正式版として出すときだけ `develop` の内容を `main` へ -送る。**これにより、マージとリリースが別の操作になる。** +開発の変更は `develop` へマージし、正式版として出すときだけ `develop` の承認したコミットを +`main` へ送る。**これにより、マージとリリースが別の操作になる。** 分けない場合、マージした時点で常用する利用者へ届く。レビューを通った変更であっても、利用者の 環境で動くかはリリースした後にしか分からない。`release` が定めた「検証リリース → 本番リリース」の @@ -133,8 +133,24 @@ ref の書き方は `owner/repo@ref` と `git-url#ref` の 2 つで、 ## 正式版を出す -**`develop` は開発の本流で、`main` は動かした先である。** 正式版のたびに `develop` から `main` -への Pull Request を作り、管理者の bypass でマージする。`main` へ直接コミットしない。 +**`develop` は開発の本流で、`main` は動かした先である。** 正式版のたびに、承認ゲート 2 で承認したコミット +(`develop` のコミット)から `release/v<版>` を切って版上げのコミットを積み、`release/v<版>` を先に `develop` へ +取り込んでから、`release/v<版>` を head にした `main` 宛の Pull Request を管理者の bypass でマージする。 +`main` へ直接コミットしない。`main` 宛の Pull Request は `develop` に含まれるコミットだけを出す(head は +`develop` か、先頭が `origin/develop` の祖先の `release/v<版>`。`scripts/check-pr-base.sh`)。 + +**承認の後に `develop` が進んでも、配布は止まらない。** 本番の配布(`release-steps.py release --channel prod`)が +承認したコミットと比べる相手は `origin/release/v<版>` の先端で、`develop` の先端ではない。`release/v<版>` に +承認したコミットと版上げの外のコミットがあれば、承認ゲート(終了コード 10)で止まる。承認の後に `develop` へ +入った変更は、この版の `main` には入らず次の配布で出る。 + +**`release/v<版>` を `develop` へ取り込むときに版数を持つ箇所が衝突したら、本番の版を取る。** 承認の後に +開発版の配布が `develop` の版数を上げていると衝突する。`release/v<版>` そのものへは `develop` を取り込まず +(取り込むと承認の外のコミットが `main` へ出る)、`release/v<版>-sync` に `origin/develop` を取り込んで衝突した塊の +`release/v<版>` の側を取り、`release/v<版>-sync` → `develop` の Pull Request で取り込む。版数の外の箇所が +衝突すれば解かずに止まる。取り込んだ後の `develop` の版数は本番の版になり、次の開発版の配布は次に空いている版で +決め直す。取り込みはマージのコミットで行い(squash と rebase では行わない)、`release/v<版>` の先頭を +`develop` の祖先に残す。 **`git push origin develop:main` は使えない。** `main` / `develop` を守る ruleset の bypass は `pull_request` で作ってあり、**このモードは Pull Request のマージだけを通し、直接 push は @@ -156,12 +172,13 @@ remote: **この手順では `main` に `develop` へ無いマージコミットが 1 つ積まれる。** そのため `main` は `develop` の fast-forward から外れる。**それでよい。** リリースした版を指すのは `main` の先端で -あって、両者が同じコミットを指していることではない。中身が一致していることは差分で確かめる。 +あって、両者が同じコミットを指していることではない。中身が出した `release/v<版>` と一致していることは +差分で確かめる(承認の後に `develop` が進んでいれば、`develop` とは一致しない)。 ```bash -gh pr create --base main --head develop --title "Release: v<版>" --body "..." +gh pr create --base main --head release/v<版> --title "Release: v<版>" --body "..." gh pr merge <番号> --merge --admin # 承認 1 件必須。メンテナーが 1 人の間は bypass で通す -git fetch origin && git diff --stat origin/develop origin/main # 空であること +git fetch origin && git diff --stat origin/release/v<版> origin/main # 空であること ``` **`--admin` を使うのは、GitHub が自分の Pull Request を自分で承認できないためである。** @@ -179,7 +196,7 @@ git fetch origin && git diff --stat origin/develop origin/main # 空である `README.md`・`AGENTS.md`・`plugins/**/README.md`・この文書・`docs/metrics/ndf-token-usage/` の記録で、`plugin.json` と `marketplace.json` は `version` / `description` の行だけ - `develop` への push でその workflow を通ったコミットがあり、head がそこから版数と説明しか - 変えていない(`develop` → `main`)。`main` の側に `develop` へ無い変更があれば省かない + 変えていない(`main` 宛の本番の Pull Request)。`main` の側に `develop` へ無い変更があれば省かない コードやテストを 1 行でも変えた差分では省かない。`develop` / `main` への push では常に回す。 省いても必須のチェックの名前は変わらず、結果は成功で返る。`pytest` はまとめのジョブが、 diff --git a/plugins/ndf/skills/release/references/release-steps.md b/plugins/ndf/skills/release/references/release-steps.md index 334a57043..a94cad483 100644 --- a/plugins/ndf/skills/release/references/release-steps.md +++ b/plugins/ndf/skills/release/references/release-steps.md @@ -163,8 +163,14 @@ python3 "$SCRIPTS/release-steps.py" release --version <版> --channel prod --app 資料の `## 版を上げる他のプラグイン` の節(0 件なら「- 無し」)と同意の行を書き、`--set` は承認ゲート 2 で決めた 上げ幅へ行を書き直す(表に無い名前・知らない上げ幅は 2)。`--decided` は表の上げ幅で `to` を決め、表が無い・ 上げ幅を読めない・版や集合が差分と合わないときは PATCH へ倒さずに 1 で止まる。他のプラグインが 0 件なら表を読まない -- `release`: `dev` は `release/v<版>` → `develop` の Pull Request を作り、チェックを待ってマージする。 - `prod` は続けて `develop` → `main` をマージし、タグと GitHub Release を作る。`items[]` +- `release`: `dev` は `release/v<版>` → `develop` の Pull Request を作り、チェックを待ってマージのコミットで取り込む。 + 取り込みが版数を持つ箇所だけで衝突すれば、`release/v<版>-sync` に `origin/develop` を取り込んで本番の版の側を取り、 + `release/v<版>-sync` → `develop` の Pull Request で取り込む(`release/v<版>` そのものへは `develop` を取り込まない)。 + 版数の外の箇所が衝突すれば解かずに 1 で止まる(`release_lib/version_merge.py`)。`metrics.release_pr` は照合に使う + `release/v<版>` の Pull Request、`metrics.sync_pr` は `-sync` で取り込んだときのその Pull Request である。 + `prod` は続けて `release/v<版>` を head にした `main` 宛の Pull Request をマージし、タグと GitHub Release を作る。 + `main` 宛の Pull Request がマージ済みなら(途中で止まった配布の再開)、比較とマージを飛ばし、タグと GitHub Release のうち + 無いものだけを作る(`items[]` の `result: already_merged`・`exists`)。`items[]` (マージした Pull Request・タグ・GitHub Release)がリリース完了の確認で照会した結果である。 `prod` は最後に「退避先の回収」の候補を挙げ、`items[]` の `kind: trash`(`result: candidate`)と `metrics.sweep_candidates` に載せる。消すのは人の承認(`ndf-ask:redline:C4`)を得た後である(下の節)。 @@ -173,11 +179,12 @@ python3 "$SCRIPTS/release-steps.py" release --version <版> --channel prod --app - `release --channel prod` の承認したコミット: `--approved-sha ` か `--approval <承認資料>` のどちらかが要る (どちらも無い・形の誤りは 2、資料・欄が無いと 3。どれも何もマージしない)。`--approval` は承認の記録の SHA が 承認したコミットと同じときだけ受け取り、違えば何もマージせずに `gate`(10・`result: not_approved`)で止まる。 - 配布の PR をマージした後、`origin/<ベースブランチ>` の先端(比べた先端)までに配布の PR の外のコミットか中身が - あれば、本番チャネルの PR・タグ・GitHub Release を作らずに `gate`(10)で止まる。ただし差分がすべて許可リスト + 配布の PR をマージした後、承認したコミットから `origin/release/v<版>` の先端(比べた先端)までに `release/v<版>` の + Pull Request の外のコミットか中身があれば、本番チャネルの PR・タグ・GitHub Release を作らずに `gate`(10)で止まる。 + 承認の後に `develop` が進んでいても比べる相手ではないため止まらず、進んだ分は次の配布で出る。ただし差分がすべて許可リスト (`docs/`・`issues/`。`release_lib/approval.py` の `UNGATED`)の下に収まる外のコミット(設計文書・用語集だけの PR など)は止めずに通し、 `items[]` に `result: outside_unshipped` で、件数を `metrics.tolerated` に残す。本番チャネルへは比べた先端だけを - マージし(`merged-steps.py merge-when-green --expect-head`)、CI 待ちの間に先端が進んだときは比べ直す。進んだ分が + マージし(`merged-steps.py merge-when-green --expect-head`)、CI 待ちの間に `release/v<版>` の先端が進んだときは比べ直す。進んだ分が 配布物に触れなければ比べ直した先端で入れ直し(3 回まで)、触れれば `gate` で止まる。 `--channel dev` には渡さない(2) From 9975bdb12bdde16e4417913b3a40ce356d7e9b5d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 08:00:15 +0000 Subject: [PATCH 31/70] =?UTF-8?q?fix(worktree):=20=E4=B8=BB=E3=83=87?= =?UTF-8?q?=E3=82=A3=E3=83=AC=E3=82=AF=E3=83=88=E3=83=AA=E3=81=AE=E8=BF=BD?= =?UTF-8?q?=E5=BE=93=E3=81=AE=20checkout=20=E3=81=A8=20fetch=20=E3=82=92?= =?UTF-8?q?=20ndf-main.lock=20=E3=81=AE=E6=8E=92=E4=BB=96=E3=81=A7=201=20?= =?UTF-8?q?=E6=9C=AC=E3=81=9A=E3=81=A4=E3=81=AB=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?#2436=20=E3=81=AE=E7=95=B0=E5=B8=B8=E7=B3=BB=208=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flock(1) と lib/locks.py の exclusive は同じ <主ディレクトリ>/.git/ndf-main.lock で互いを待つ(実測で 1.21 秒待った)。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/worktree-session.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/scripts/worktree-session.sh b/plugins/ndf/scripts/worktree-session.sh index 93e2cbb3f..93791fecb 100755 --- a/plugins/ndf/scripts/worktree-session.sh +++ b/plugins/ndf/scripts/worktree-session.sh @@ -151,9 +151,21 @@ fi # --- ブランチ追従 ----------------------------------------------------------- +# 主ディレクトリの index か HEAD を動かす git の操作は、`<主ディレクトリ>/.git/ndf-main.lock` の排他で +# 1 本ずつにする(並列の計画のレーンが同時に動かすと `.git/index.lock` が衝突する。#2436 の異常系 8)。 +# Python の側(`lib/locks.py` の `exclusive(<主ディレクトリ>/.git/ndf-main)`)と同じファイルで互いを待つ。 +# flock が無い・主ディレクトリの .git がディレクトリでないときは排他を取らずに打つ。 +main_git() { + if command -v flock >/dev/null 2>&1 && [ -d "$MAIN_DIR/.git" ]; then + flock -w 60 "$MAIN_DIR/.git/ndf-main.lock" git -C "$MAIN_DIR" "$@" + else + git -C "$MAIN_DIR" "$@" + fi +} + follow_to() { local target="$1" label="$2" - if git -C "$MAIN_DIR" checkout -q --detach "$target" 2>/dev/null; then + if main_git checkout -q --detach "$target" 2>/dev/null; then MESSAGES+=("主ディレクトリを ${label} が指すコミットへ detached HEAD で合わせました。") else MESSAGES+=("主ディレクトリを ${label} へ合わせられませんでした。作業は続けられます。") @@ -188,7 +200,7 @@ resolve_commit() { resolve_base_commit() { local name="$1" sha sha=$(resolve_commit "$name") && { printf '%s\n' "$sha"; return 0; } - GIT_TERMINAL_PROMPT=0 git -C "$MAIN_DIR" fetch -q --no-tags origin \ + GIT_TERMINAL_PROMPT=0 main_git fetch -q --no-tags origin \ "refs/heads/$name" >/dev/null 2>&1 || return 1 sha=$(git -C "$MAIN_DIR" rev-parse --verify --quiet 'FETCH_HEAD^{commit}' 2>/dev/null) || return 1 [ -n "$sha" ] || return 1 From 17504cb6868cba7e3f6de8df39fb8f2a5270f68c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 08:09:56 +0000 Subject: [PATCH 32/70] =?UTF-8?q?feat(parallel):=20=E7=95=B0=E5=B8=B8?= =?UTF-8?q?=E7=B3=BB=E3=82=92=E8=80=90=E4=B9=85=E3=83=AF=E3=83=BC=E3=82=AF?= =?UTF-8?q?=E3=83=95=E3=83=AD=E3=83=BC=E3=81=A7=E6=89=B1=E3=81=86=EF=BC=88?= =?UTF-8?q?=E5=8B=95=E3=81=8B=E3=81=AA=E3=81=84=E3=83=AC=E3=83=BC=E3=83=B3?= =?UTF-8?q?=E3=83=BB=E4=B8=80=E6=99=82=E3=83=AC=E3=83=BC=E3=83=B3=E3=81=AE?= =?UTF-8?q?=E5=81=9C=E6=AD=A2=E3=83=BB=E5=90=8C=E3=81=98=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=E3=81=AE=E5=95=8F=E3=81=84=E3=83=BB=E8=B5=B7=E5=8B=95=E3=81=AE?= =?UTF-8?q?=E6=96=AD=E3=82=8A=E3=83=BBstop=20--force=E3=83=BB=E7=B5=82?= =?UTF-8?q?=E3=82=8F=E3=82=8A=E3=81=AE=E5=89=8D=E3=81=AE=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E3=81=AE=E5=91=A8=E5=9B=9E=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 観測が最後に動いた時刻を読み、区切り(既定 60 分)を越えたレーンを区切りごとに 1 回だけ問う - 配布の途中で一時レーンが止まり、settle も承認ゲート 2 の依頼も無ければ問う - 区分と伏せた文面が同じ問いは 1 つにまとめ、答えを依頼した全レーンへ立てる - 起動の断りが 3 回続いたら理由を添えて問う - parallel.py stop --force は settle を待たずに終え、一時レーンも止める - 答えていない問いが残る間は終えない。止まったループのレーンに答えを受けた保留の周回があれば、一時レーンで loop pending を流してから終える Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_run.py | 162 ++++++++++++++++-- plugins/ndf/scripts/parallel.py | 6 +- .../ndf/scripts/tests/test_parallel_run.py | 94 +++++++++- 3 files changed, 242 insertions(+), 20 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 6ae3e4914..911d62876 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -127,11 +127,55 @@ def step_spawn(main: str, name: str, lanes: list[str]) -> dict: return out +STALL_MINUTES = 60 # 動かないレーンを問うまでの分(設計の失敗の 3 番の既定) +SPAWN_REFUSALS = 3 # 起動の断りがこの回数続いたら問う(設計の失敗の 17 番) + + +def stall_seconds() -> float: + """動かないレーンを問うまでの秒(`NDF_PARALLEL_STALL_MINUTES` で変えられる。既定 60 分)。""" + try: + return max(1.0, float(os.environ.get("NDF_PARALLEL_STALL_MINUTES") or STALL_MINUTES)) * 60 + except ValueError: + return STALL_MINUTES * 60.0 + + @durable.step(name="parallel.observe") def step_observe(main: str) -> dict: - """`relay.py ls --json` のレーンの状態(名前 → running / awaiting_approval / stopped)。""" + """`relay.py ls --json` の行(名前 → {state, at, role})と、観た時刻(エポック秒)。""" _, res = relay(Path(main), "ls", "--json") - return {it["name"]: it.get("state") for it in res.get("items") or [] if isinstance(it, dict) and it.get("name")} + rows = { + it["name"]: {"state": it.get("state"), "at": it.get("at"), "role": it.get("role")} + for it in res.get("items") or [] + if isinstance(it, dict) and it.get("name") + } + return {"now": time.time(), "rows": rows} + + +@durable.step(name="parallel.held-loops") +def step_held_loops(main: str, name: str, answered: list[str]) -> list[str]: + return held_loops(Path(main), name, answered) + + +def held_loops(main: Path, name: str, answered: list[str]) -> list[str]: + """答えを受けたのに残りを流していない保留の周回を持つ、計画のループのレーンのループ名(異常系 11)。""" + try: + plan = pp.ParallelPlan.model_validate(pp.load_plan(main, name), context={"max_plans": 10**6, "max_named": 10**6}) + except (OSError, ValueError): + return [] + out = [] + for ln in plan.lanes: + if ln.input.kind != "loop": + continue + try: + rec = json.loads((Path(main) / ".ndf" / "loop" / ln.input.loop / "loop.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + for lap in rec.get("laps") or []: + part = (lap.get("release") or lap.get("question") or {}) if lap.get("outcome") in ("配布待ち", "質問待ち") else {} + if part.get("request") in answered: + out.append(ln.input.loop) + break + return out @durable.step(name="parallel.build") @@ -205,6 +249,11 @@ def __init__(self, main: str, name: str, lanes: list[dict]): self.launch: Optional[dict] = None self.received: dict[str, int] = {} self.answered: set[str] = set() + self.force = False + self.refusals: dict[str, int] = {} + self.stall_until: dict[str, float] = {} + self.helper_asked: Optional[int] = None + self.held_spawned: list[str] = [] def bump(self) -> int: self.seq += 1 @@ -237,6 +286,7 @@ def take(self, msg) -> None: kind = msg.get("kind") if kind == "stop": self.stop = True + self.force = self.force or bool(msg.get("force")) elif kind == "request": if all(r["id"] != msg.get("id") for r in self.pending): self.pending.append(msg) @@ -245,9 +295,15 @@ def take(self, msg) -> None: elif kind in ("gate1", "gate2", "ask"): if any(q.get("id") == msg.get("id") for q in self.questions) or msg.get("id") in self.answered: return # 同じ依頼の打ち直しは数えない(id は送る側が依頼の中身から決める) + same = next((q for q in self.questions if msg.get("kind") == "ask" and q.get("cause") == cause_of(msg)), None) + if same is not None: # 同じ原因の問いは 1 つにまとめ、答えを依頼してきた全レーンへ配る(異常系 5) + same["also"] = [*same.get("also", []), msg.get("id")] + same["lanes"] = [*same.get("lanes", [same.get("lane")]), msg.get("lane")] + self.publish_questions() + return if msg.get("source") == "ndf-ask:human-action": # 代われない操作: レーンへ入る 1 行を添える(6d) msg = {**msg, "attach": f"python3 {RELAY} attach {msg.get('lane')}"} - self.questions.append(msg) + self.questions.append({**msg, "cause": cause_of(msg)} if msg.get("kind") == "ask" else msg) self.publish_questions() elif kind == "answer": self.reply(msg) @@ -260,12 +316,23 @@ def reply(self, msg: dict) -> None: rid = msg.get("id") if rid in self.answered or all(q.get("id") != rid for q in self.questions): return - self.answered.add(rid) - durable.set_event(f"answer-{rid}", {k: msg.get(k) for k in ("result", "note", "choice", "stamp", "at")}) + q = next(q for q in self.questions if q.get("id") == rid) + for each in [rid, *q.get("also", [])]: + self.answered.add(each) + durable.set_event(f"answer-{each}", {k: msg.get(k) for k in ("result", "note", "choice", "stamp", "at")}) self.questions = [q for q in self.questions if q.get("id") != rid] self.publish_questions() + def ask(self, kind: str, rid: str, lane: str, question: str, options: list[str], **extra) -> None: + """耐久ワークフローが自分で立てる問い(動かないレーン・一時レーンの停止・起動の断り)。同じ id は 1 回だけ。""" + if rid in self.answered or any(q.get("id") == rid for q in self.questions): + return + item = {"kind": kind, "id": rid, "lane": lane, "source": "ndf-ask:human-action", "question": question, "options": options} + self.questions.append({**item, "attach": f"python3 {RELAY} attach {lane}", **extra}) + self.publish_questions() + def answer(self, rid: str, result: str, version: Optional[str], n: Optional[int], reason: Optional[str] = None) -> None: + self.answered.add(rid) durable.set_event(f"answer-{rid}", {"result": result, "version": version, "release": n, "reason": reason, "at": None}) # -- 配布 -- @@ -371,11 +438,10 @@ def settle(self, rel: dict, msg: dict) -> None: self.current = None -def helper_spawn(name: str, n: int, k: int) -> str: - """一時レーンを起こす 1 行(`metrics.spawn`)。""" - first = ( - f"/goal /ndf:development-workflow --parallel-helper {name} {n} {k} -- 終わり: [ndf-parallel] helper-done {name} の行を出したとき" - ) +def helper_spawn(name: str, n: int, k: int, pending: Optional[str] = None) -> str: + """一時レーンを起こす 1 行(`metrics.spawn`)。pending はループ名で、保留の周回の残りを流す一時レーン(異常系 11)。""" + what = f"pending {pending}" if pending else f"{n} {k}" + first = f"/goal /ndf:development-workflow --parallel-helper {name} {what} -- 終わり: [ndf-parallel] helper-done {name} の行を出したとき" return f"python3 {RELAY} spawn --name {pr.helper_name(name, n)} --role helper --plan {name} --json -- '{first}'" @@ -412,7 +478,7 @@ def parallel_workflow(main: str, name: str) -> dict: run.pause("running") wait = poll_seconds() while True: - if run.stop and run.current is None: + if run.stop and (run.current is None or run.force): # --force は配布の settle を待たず、一時レーンも止める return _handle_stop(run, main) if run.stop: # 一時レーンが配布を流している間(提示のまま MVV 判定で本番へ進む場合も含む)の stop は、 # settle が届くまで待ってから終える(設計の失敗の 12 番。作りかけを残さない) @@ -426,8 +492,16 @@ def parallel_workflow(main: str, name: str) -> dict: if run.pending: run.open_release() if _run_finished(run): - step_finish(main, name, run.progress()) - return run.summary("finished") + held = step_held_loops(main, name, sorted(run.answered)) + if not held: + step_finish(main, name, run.progress()) + return run.summary("finished") + # 止まったレーンの保留の周回のまとめを一時レーンで流してから終える(異常系 11)。一時レーンは 1 本ずつ起こす + todo = [x for x in held if x not in run.held_spawned] + if todo and not set(run.held_spawned) & set(held): + run.held_spawned.append(todo[0]) + seq = len(run.releases) + len(run.held_spawned) + run.pause("held", loops=[todo[0]], spawn=helper_spawn(name, seq, 0, pending=todo[0])) run.take(durable.recv("inbox", wait if run.current is None else min(wait, 30))) if run.current is not None: msg = durable.recv("resume", min(wait, 30)) @@ -442,7 +516,8 @@ def _started(run: _Run) -> list[str]: def _handle_stop(run: _Run, main: str) -> dict: """止める依頼を受けた後: 起動済みのレーンを止め、記録を閉じて要約を返す。""" - left = step_stop_lanes(main, _started(run)) + helper = [pr.helper_name(run.name, run.current["n"])] if run.force and run.current is not None else [] + left = step_stop_lanes(main, _started(run) + helper) step_finish(main, run.name, run.progress()) return {**run.summary("stopped"), "not_stopped": left} @@ -453,16 +528,24 @@ def _spawn_ready(run: _Run, main: str) -> None: if ready: for n, got in step_spawn(main, run.name, [ln.name for ln in ready]).items(): run.lanes[n].update(got) + if got.get("refused"): + run.refusals[n] = run.refusals.get(n, 0) + 1 + if run.refusals[n] == SPAWN_REFUSALS: # 3 回続けて断られたら理由を添えて問う(異常系 17) + run.ask("spawn", f"{run.name}-spawn-{n}", n, f"レーン {n} を {SPAWN_REFUSALS} 回起動できない({got['refused']})", ["起こし直す", "止める"]) def _run_finished(run: _Run) -> bool: - """配布の途中でも残りでもなく、全レーンが止まっている。""" - return run.current is None and not run.pending and len(run.stopped()) == len(run.order) + """配布の途中でも残りでもなく、答えていない問いも無く、全レーンが止まっている(I11)。""" + return run.current is None and not run.pending and not run.questions and len(run.stopped()) == len(run.order) def _observe_run(run: _Run, main: str) -> None: """レーンの状態を観測して反映し、承認待ちへ移ったレーンを知らせる。""" - states = step_observe(main) + seen = step_observe(main) + rows = seen.get("rows") or {} + states = {n: r.get("state") for n, r in rows.items()} + _ask_stalls(run, rows, float(seen.get("now") or 0)) + _ask_helper(run, rows) noticed = [] for n in run.order: st = states.get(n) @@ -476,6 +559,51 @@ def _observe_run(run: _Run, main: str) -> None: run.pause("notice", lanes=noticed, attach=[f"python3 {RELAY} attach {n}" for n in noticed]) +def _ask_stalls(run: _Run, rows: dict, now: float) -> None: + """承認待ちでも止まってもいないのに最後に動いた時刻が区切りより前のレーンを、区切りごとに 1 回だけ問う(異常系 3)。""" + import clock + + limit = stall_seconds() + for n in run.order: + r = rows.get(n) or {} + at = clock.parse(r.get("at"), naive="utc") if r.get("at") else None + if r.get("state") != "running" or at is None or now - at.timestamp() < limit or now < run.stall_until.get(n, 0): + continue + run.stall_until[n] = now + limit + mins = int((now - at.timestamp()) // 60) + run.ask( + "stall", f"{n}-s{int(now)}", n, f"レーン {n} が {mins} 分動いていない", ["起こし直す", "止める", "待つ"], + commands={"起こし直す": f"python3 {RELAY} restart {n}", "止める": f"python3 {RELAY} stop {n}"}, + ) # fmt: skip + + +def _ask_helper(run: _Run, rows: dict) -> None: + """配布の途中で一時レーンが止まり、settle も承認ゲート 2 の依頼も届いていなければ問う(受け入れ条件 6g)。""" + rel = run.current + if rel is None or not run.launch or run.helper_asked == run.launch["seq"]: + return + name = pr.helper_name(run.name, rel["n"]) + if (rows.get(name) or {}).get("state") != "stopped" or any(q.get("kind") == "gate2" and q.get("n") == rel["n"] for q in run.questions): + return + run.helper_asked = run.launch["seq"] + run.ask( + "helper", f"{name}-h{run.launch['seq']}", name, f"一時レーン {name} が配布 {rel['n']} の途中で止まった(settle も承認ゲート 2 の依頼も無い)", + ["続きから起こし直す", "止める"], commands={"続きから起こし直す": run.launch.get("spawn")}, + ) # fmt: skip + + +def cause_of(msg: dict) -> str: + """同じ原因の問いの鍵: 区分と、空白をまとめてレーン名・パス・番号を伏せた問いの文面(異常系 5)。""" + import re + + text = str(msg.get("question") or "") + if msg.get("lane"): + text = text.replace(str(msg["lane"]), "") + text = re.sub(r"(/[^\s()()]+)+", "", text) + text = re.sub(r"\d+", "", text) + return f"{msg.get('source')}|{' '.join(text.split())}" + + def _plan_view(run: _Run) -> pp.ParallelPlan: """起動の判定に使う計画の形(耐久ワークフローの入力から作る。ファイルを読まない)。""" lanes = [ diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index 150783ce8..ec5ed98ea 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -11,7 +11,7 @@ | `present <計画名> <連番> <提示> --approval <承認資料>` | 範囲の Pull Request をレーンへ対応づけ、提示の置き場(`releases/<連番>/<提示>/`)へ書く | | `confirm <計画名> <連番> [--take new\\|first]` | 承認ゲート 2 の承認を送る | | `settle <計画名> <連番> (--done \\| --rejected [--reason 文])` | 本番の配布の結果を送る | -| `stop <計画名>` | 計画の全レーンを止め、耐久ワークフローを終える | +| `stop <計画名> [--force]` | 計画の全レーンを止め、耐久ワークフローを終える(配布の途中は settle を待つ。`--force` は待たない) | | `request <計画名> --lane <名> --state <パス> [--prs ...] [--issues ...]` | 配布の依頼を送る(10)か、すでに `main` にあれば送らない(0) | | `await <計画名> --from <パス> [--timeout 秒]` | 依頼の答えを読む(released 0・rejected 10・failed 1・まだ無い 3) | | `request-ask <計画名> --lane <名>` | 標準入力の問い(`tool_input`・`cwd`・`ledger_id`)をオーガナイザーへ送る(送り手でなければ 4) | @@ -291,7 +291,8 @@ def cmd_stop(a) -> None: reply("stopped", f"並列の計画 {a.name} の検査に誤りがある", items, 1) if pl.run_status(main, a.name) in pl.RUNNING: wid = pl.workflow_id(a.name) - durable.send_to(pl.KIND, pl.record_identity(main, a.name), wid, {"kind": "stop"}, "inbox", f"{wid}:inbox:stop") + msg, key = ({"kind": "stop", "force": True}, f"{wid}:inbox:stop:force") if a.force else ({"kind": "stop"}, f"{wid}:inbox:stop") + durable.send_to(pl.KIND, pl.record_identity(main, a.name), wid, msg, "inbox", key) left = [ln.name for ln in plan.lanes if pr_run.relay(main, "stop", ln.name, "--json")[0] not in (0, 3)] if left: reply("stopped", f"止まらないレーンがある: {', '.join(left)}", [{"lane": n} for n in left], 1, not_stopped=left) @@ -421,6 +422,7 @@ def build_parser() -> argparse.ArgumentParser: run_.add_argument("name") stp = sub.add_parser("stop") stp.add_argument("name") + stp.add_argument("--force", action="store_true", help="配布の途中でも settle を待たずに終え、一時レーンも止める") w = sub.add_parser("wait") w.add_argument("name") w.add_argument("--after", type=int, default=0) diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index 0ef47f8bb..1f040018c 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -45,7 +45,11 @@ sub = sys.argv[1] if sub == "ls": st = json.load(open(states)) if os.path.exists(states) else {} - print(json.dumps({"items": [{"name": n, "state": s} for n, s in st.items()], "metrics": {"hub": True, "tmux": True}})) + rows = [{"name": n, **(s if isinstance(s, dict) else {"state": s})} for n, s in st.items()] + print(json.dumps({"items": rows, "metrics": {"hub": True, "tmux": True}})) + elif sub == "spawn" and sys.argv[3] in os.environ.get("FAKE_REFUSE", "").split(): + print(json.dumps({"items": [{"result": "limit"}], "metrics": {}})) + sys.exit(1) else: print(json.dumps({"items": [{"result": "started"}], "metrics": {}})) """ @@ -422,3 +426,91 @@ def test_request_ask_cli_refuses_a_sender_outside_the_plan_with_4(env, monkeypat cwd=str(env["main"]), ) assert got.returncode == 4 and json.loads(got.stdout)["status"] == "stopped" + + +# ---- 異常系(設計 issue-2436-design-failures.md の 3・5・11・12・17 と一時レーンの停止) ---- + +OLD = "2000-01-01T00:00:00Z" + + +def test_a_stalled_lane_is_asked_once(env): + main, states = env["main"], env["states"] + start(main) + until(lambda: spawned(env["log"]) == ["la"]) + states.write_text(json.dumps({"la": {"state": "running", "at": OLD}})) + item = until(lambda: [i for i in questions(main)["items"] if i["kind"] == "stall"])[0] + assert item["lane"] == "la" and item["options"] == ["起こし直す", "止める", "待つ"] and "restart la" in item["commands"]["起こし直す"] + time.sleep(0.8) + assert len([i for i in questions(main)["items"] if i["kind"] == "stall"]) == 1 # 次の区切りまで立てない + + +def test_questions_of_the_same_cause_are_asked_once_and_answered_to_each(env, tmp_path): + main = env["main"] + env["states"].write_text(json.dumps({"la": "running", "lb": "running"})) + start(main) + a = pl.request_ask(main, "p1", "la", {**ASK, "questions": [{"question": "gh の認証が切れた(/work/la の #12)", "options": []}]}, str(tmp_path), "x1") + b = pl.request_ask(main, "p1", "lb", {**ASK, "questions": [{"question": "gh の認証が切れた(/work/lb の #34)", "options": []}]}, str(tmp_path), "x2") + item = until(lambda: [i for i in questions(main)["items"] if b in (i.get("also") or [])])[0] + assert item["id"] == a and len(questions(main)["items"]) == 1 + pl.send_answer(main, "p1", a, "answered", choice=["済んだ"]) + until(lambda: all(pl.answers_of(main, "p1", [a, b])[r]["answer"] for r in (a, b))) + + +def test_three_spawn_refusals_raise_a_question(env, monkeypatch): + monkeypatch.setenv("FAKE_REFUSE", "la") + main = env["main"] + start(main) + item = until(lambda: [i for i in questions(main)["items"] if i["kind"] == "spawn"])[0] + assert item["lane"] == "la" and "limit" in item["question"] and len(calls(env["log"], "spawn")) >= 3 + + +def test_a_stopped_helper_without_settle_is_asked(env, tmp_path): + main, states = env["main"], env["states"] + start(main) + pl.send_release_request(main, "p1", "la", [10], [1], tmp_path / "st") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release") + states.write_text(json.dumps({"la": "running", "p1-rel1": {"state": "stopped", "role": "helper"}})) + item = until(lambda: [i for i in questions(main)["items"] if i["kind"] == "helper"])[0] + assert item["options"] == ["続きから起こし直す", "止める"] and "p1-rel1" in item["question"] + + +def test_stop_force_does_not_wait_for_settle_and_stops_the_helper(env, tmp_path): + main, log = env["main"], env["log"] + start(main) + pl.send_release_request(main, "p1", "la", [10], [1], tmp_path / "st") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release") + wid = pl.workflow_id("p1") + durable.send_to(pl.KIND, pl.record_identity(main, "p1"), wid, {"kind": "stop", "force": True}, "inbox", f"{wid}:inbox:stop:force") + out = durable.output_of(wid) + assert out["status"] == "stopped" and ["stop", "p1-rel1", "--json"] in calls(log, "stop") + + +def test_answered_pending_laps_of_stopped_loop_lanes_are_closed_by_a_helper_before_finishing(env, monkeypatch, tmp_path): + main, states = env["main"], env["states"] + monkeypatch.setattr(prun, "held_loops", lambda m, n, answered: ["lroot"] if answered else []) + start(main) + rid = pl.send_release_request(main, "p1", "la", [10], [1], tmp_path / "st") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release") + (prl.release_dir(main, "p1", 1, 1) / "presented.json").write_text(json.dumps({"sha": "sha1", "prs": [10]})) + resume(main, {"n": 1, "phase": "confirm"}, "c1") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "confirmed") + resume(main, {"n": 1, "phase": "settle", "result": "released", "version": "1.2.3"}, "s1") + until(lambda: pl.read_answer(main, "p1", rid)[0]) + states.write_text(json.dumps({"la": "stopped", "lb": "stopped"})) + ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "held" and e) + assert ev["loops"] == ["lroot"] and "--parallel-helper p1 pending lroot" in ev["spawn"] + assert durable.status(pl.workflow_id("p1")) in pl.RUNNING # 流し終えるまで finished にしない + + +def test_the_plan_does_not_finish_while_a_question_is_unanswered(env, tmp_path): + main, states = env["main"], env["states"] + states.write_text(json.dumps({"la": "running"})) + start(main) + rid = pl.request_ask(main, "p1", "la", ASK, str(tmp_path), "led9") + until(lambda: questions(main)["items"]) + states.write_text(json.dumps({"la": "stopped", "lb": "stopped"})) + until(lambda: "lb" in spawned(env["log"])) + time.sleep(1.0) + assert durable.status(pl.workflow_id("p1")) in pl.RUNNING + pl.send_answer(main, "p1", rid, "answered", choice=["済んだ"]) + assert durable.output_of(pl.workflow_id("p1"))["status"] == "finished" From 542a09bfcf91286d99ba7898d1bccc1c6db56789 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 08:11:45 +0000 Subject: [PATCH 33/70] =?UTF-8?q?fix(parallel):=20=E5=90=8C=E3=81=98?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0=E3=81=AE=E5=95=8F=E3=81=84=E3=81=AE=E3=81=BE?= =?UTF-8?q?=E3=81=A8=E3=82=81=E3=82=92=E4=BA=BA=E3=81=AB=E3=81=97=E3=81=8B?= =?UTF-8?q?=E3=81=A7=E3=81=8D=E3=81=AA=E3=81=84=E6=93=8D=E4=BD=9C=E3=81=AB?= =?UTF-8?q?=E9=99=90=E3=82=8A=E3=80=81=E9=81=B8=E6=8A=9E=E8=82=A2=E3=81=AE?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E3=82=92=E6=B1=82=E3=82=81=E3=80=81=E5=90=84?= =?UTF-8?q?=E3=83=AC=E3=83=BC=E3=83=B3=E3=81=AE=E5=85=83=E3=81=AE=E6=96=87?= =?UTF-8?q?=E9=9D=A2=E3=82=92=E6=AE=8B=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 取り消せない操作・レッドライン・費用の問いは、番号を伏せると別の対象(別の Pull Request など)を 1 つの答えで承認し得るため、まとめない。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_run.py | 17 ++++++++++++----- plugins/ndf/scripts/tests/test_parallel_run.py | 12 +++++++++++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 911d62876..00896325e 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -295,15 +295,16 @@ def take(self, msg) -> None: elif kind in ("gate1", "gate2", "ask"): if any(q.get("id") == msg.get("id") for q in self.questions) or msg.get("id") in self.answered: return # 同じ依頼の打ち直しは数えない(id は送る側が依頼の中身から決める) - same = next((q for q in self.questions if msg.get("kind") == "ask" and q.get("cause") == cause_of(msg)), None) + key = cause_of(msg) + same = next((q for q in self.questions if key and q.get("cause") == key), None) if same is not None: # 同じ原因の問いは 1 つにまとめ、答えを依頼してきた全レーンへ配る(異常系 5) same["also"] = [*same.get("also", []), msg.get("id")] - same["lanes"] = [*same.get("lanes", [same.get("lane")]), msg.get("lane")] + same["merged"] = [*same.get("merged", []), {"id": msg.get("id"), "lane": msg.get("lane"), "question": msg.get("question")}] self.publish_questions() return if msg.get("source") == "ndf-ask:human-action": # 代われない操作: レーンへ入る 1 行を添える(6d) msg = {**msg, "attach": f"python3 {RELAY} attach {msg.get('lane')}"} - self.questions.append({**msg, "cause": cause_of(msg)} if msg.get("kind") == "ask" else msg) + self.questions.append({**msg, "cause": key} if key else msg) self.publish_questions() elif kind == "answer": self.reply(msg) @@ -593,15 +594,21 @@ def _ask_helper(run: _Run, rows: dict) -> None: def cause_of(msg: dict) -> str: - """同じ原因の問いの鍵: 区分と、空白をまとめてレーン名・パス・番号を伏せた問いの文面(異常系 5)。""" + """同じ原因の問いの鍵(異常系 5): 区分と選択肢と、空白をまとめてレーン名・パス・番号を伏せた問いの文面。 + + まとめるのは人にしかできない操作(`ndf-ask:human-action`。認証切れ・ディスク不足・ネットワークなど環境の問題)だけで、 + 取り消せない操作・レッドライン・費用の問いは番号の違いで別の対象になり得るため、まとめない(空の鍵を返す)。 + まとめた問いには各レーンの元の文面(`merged`)を残し、オーガナイザーが問いに並べる。""" import re + if msg.get("kind") != "ask" or msg.get("source") != "ndf-ask:human-action": + return "" text = str(msg.get("question") or "") if msg.get("lane"): text = text.replace(str(msg["lane"]), "") text = re.sub(r"(/[^\s()()]+)+", "", text) text = re.sub(r"\d+", "", text) - return f"{msg.get('source')}|{' '.join(text.split())}" + return f"{msg.get('source')}|{json.dumps(msg.get('options') or [], ensure_ascii=False)}|{' '.join(text.split())}" def _plan_view(run: _Run) -> pp.ParallelPlan: diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index 1f040018c..79d1245ea 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -451,7 +451,7 @@ def test_questions_of_the_same_cause_are_asked_once_and_answered_to_each(env, tm a = pl.request_ask(main, "p1", "la", {**ASK, "questions": [{"question": "gh の認証が切れた(/work/la の #12)", "options": []}]}, str(tmp_path), "x1") b = pl.request_ask(main, "p1", "lb", {**ASK, "questions": [{"question": "gh の認証が切れた(/work/lb の #34)", "options": []}]}, str(tmp_path), "x2") item = until(lambda: [i for i in questions(main)["items"] if b in (i.get("also") or [])])[0] - assert item["id"] == a and len(questions(main)["items"]) == 1 + assert item["id"] == a and len(questions(main)["items"]) == 1 and "/work/lb の #34" in item["merged"][0]["question"] pl.send_answer(main, "p1", a, "answered", choice=["済んだ"]) until(lambda: all(pl.answers_of(main, "p1", [a, b])[r]["answer"] for r in (a, b))) @@ -514,3 +514,13 @@ def test_the_plan_does_not_finish_while_a_question_is_unanswered(env, tmp_path): assert durable.status(pl.workflow_id("p1")) in pl.RUNNING pl.send_answer(main, "p1", rid, "answered", choice=["済んだ"]) assert durable.output_of(pl.workflow_id("p1"))["status"] == "finished" + + +def test_irreversible_questions_with_different_numbers_are_not_merged(env, tmp_path): + main = env["main"] + env["states"].write_text(json.dumps({"la": "running", "lb": "running"})) + start(main) + q = lambda n: {"questions": [{"question": f"PR #{n} を消してよいか", "options": [{"label": "はい"}]}], "metadata": {"source": "ndf-ask:irreversible"}} # noqa: E731 + pl.request_ask(main, "p1", "la", q(12), str(tmp_path), "y1") + pl.request_ask(main, "p1", "lb", q(34), str(tmp_path), "y2") + until(lambda: len(questions(main)["items"]) == 2) From 0f9c1cb0ae91d5ae428ecfc8662aff1aaa7b4ab7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 08:24:05 +0000 Subject: [PATCH 34/70] =?UTF-8?q?refactor(release):=20=E8=A1=8C=E6=95=B0?= =?UTF-8?q?=E3=81=AE=E4=B8=8A=E9=99=90=E3=82=92=E8=B6=85=E3=81=88=E3=81=9F?= =?UTF-8?q?=20release-steps.py=20=E3=81=A8=20merged-steps.py=20=E3=81=8B?= =?UTF-8?q?=E3=82=89=E9=83=A8=E5=93=81=E3=82=92=E5=88=86=E3=81=91=E3=82=8B?= =?UTF-8?q?=EF=BC=88#2436=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 宣言の決まった手順の読み込みと実行を release_lib/declared_steps.py へ、主ディレクトリの排他を merged_lib/main_lock.py へ移す。振る舞いは変えない(script-structure の lines 違反の解消)。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/merged-steps.py | 16 +- plugins/ndf/scripts/merged_lib/main_lock.py | 23 ++ plugins/ndf/scripts/release-steps.py | 190 +---------------- .../ndf/scripts/release_lib/declared_steps.py | 199 ++++++++++++++++++ 4 files changed, 226 insertions(+), 202 deletions(-) create mode 100644 plugins/ndf/scripts/merged_lib/main_lock.py create mode 100644 plugins/ndf/scripts/release_lib/declared_steps.py diff --git a/plugins/ndf/scripts/merged-steps.py b/plugins/ndf/scripts/merged-steps.py index 2fdb1e342..2c3e66d51 100755 --- a/plugins/ndf/scripts/merged-steps.py +++ b/plugins/ndf/scripts/merged-steps.py @@ -46,7 +46,6 @@ from __future__ import annotations import argparse -import contextlib import json import sys import time @@ -69,7 +68,6 @@ run, ) import gh_parts # noqa: E402 -import locks # noqa: E402 import repo # noqa: E402 import run_owner # noqa: E402 import run_state # noqa: E402 @@ -79,24 +77,12 @@ from merged_lib import base_sync, merge, trash # noqa: E402 from merged_lib.pull import blocking_local_changes, same_untracked # noqa: E402,F401 from merged_lib.checks import queued_run_count # noqa: E402 +from merged_lib.main_lock import main_dir_lock # noqa: E402 from merged_lib.pr_info import merged_pr_info # noqa: E402 from merged_lib.probe_table import PROBE_ACTIONS, PROBE_CLASSES, PROBE_REMEDIED_SUMMARY, PROBE_TABLE # noqa: E402 from merged_lib.reading import read_checks # noqa: E402 TOOL = "merged" -MAIN_LOCK_WAIT = 600.0 # メインディレクトリの排他を待つ上限(秒)。後片付け 1 件と pull は数十秒で終わる - - -@contextlib.contextmanager -def main_dir_lock(main_dir): - """メインディレクトリの index か HEAD を動かす操作を、レーンの間で 1 本ずつにする(`/ndf-main.lock`。 - #2436 の異常系 8)。取れなければ StepError(1)。""" - common = git(main_dir, "rev-parse", "--path-format=absolute", "--git-common-dir").stdout.strip() - try: - with locks.exclusive(Path(common) / "ndf-main", timeout=MAIN_LOCK_WAIT): - yield - except locks.LockTimeout as e: - raise StepError(f"メインディレクトリの排他を取れない: {e}") from None def list_worktrees(root): diff --git a/plugins/ndf/scripts/merged_lib/main_lock.py b/plugins/ndf/scripts/merged_lib/main_lock.py new file mode 100644 index 000000000..2960f7c62 --- /dev/null +++ b/plugins/ndf/scripts/merged_lib/main_lock.py @@ -0,0 +1,23 @@ +"""主ディレクトリの排他(merged-steps.py の後片付けが使う。#2436 の異常系 8)。""" + +from __future__ import annotations + +import contextlib +from pathlib import Path + +import locks +from step_result import StepError, git + +MAIN_LOCK_WAIT = 600.0 # メインディレクトリの排他を待つ上限(秒)。後片付け 1 件と pull は数十秒で終わる + + +@contextlib.contextmanager +def main_dir_lock(main_dir): + """メインディレクトリの index か HEAD を動かす操作を、レーンの間で 1 本ずつにする(`/ndf-main.lock`)。 + 取れなければ StepError(1)。""" + common = git(main_dir, "rev-parse", "--path-format=absolute", "--git-common-dir").stdout.strip() + try: + with locks.exclusive(Path(common) / "ndf-main", timeout=MAIN_LOCK_WAIT): + yield + except locks.LockTimeout as e: + raise StepError(f"メインディレクトリの排他を取れない: {e}") from None diff --git a/plugins/ndf/scripts/release-steps.py b/plugins/ndf/scripts/release-steps.py index 3d20c0f37..0795da650 100755 --- a/plugins/ndf/scripts/release-steps.py +++ b/plugins/ndf/scripts/release-steps.py @@ -79,20 +79,17 @@ import re import sys import tempfile -from dataclasses import dataclass -from pathlib import Path, PurePosixPath -from typing import Literal +from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent / "lib")) import deps # noqa: E402 deps.require("schema", "versions", "bump", "md", "mdtable", "durable", "waits") import approved_commit as ac # noqa: E402 -import schema # noqa: E402 sys.path.insert(0, str(Path(__file__).resolve().parent)) from merged_lib import trash # noqa: E402 -from release_lib import approval, bump, carry, changed, deploy, included, instructions, materials, others, record, replaced, step_run # noqa: E402 +from release_lib import approval, bump, carry, changed, deploy, included, instructions, materials, others, record, replaced # noqa: E402 from release_lib import version_merge # noqa: E402 from release_lib.approval import HeadMoved # noqa: E402 from release_lib.names import changelog_section, changelog_span, h2_lines, next_h2, plugin_of, release_decl # noqa: E402 @@ -117,190 +114,9 @@ version_arg, ) import gh_parts # noqa: E402 -import jsonio # noqa: E402 -import proc # noqa: E402 import repo as repo_lib # noqa: E402 import delivery # noqa: E402 - -DECLARATION = ".ndf/release.json" -SUPPORTED_VERSIONS = (1,) -STAGES = ("production", "verification", "any") -DEFAULT_TIMEOUT = 600 - - -class DeclarationError(Exception): - pass - - -@dataclass -class Step: - name: str - stage: str - command: list[str] - writes: list[str] - guide: str | None - timeout: int - - -def _relative(value, where: str) -> str: - if not isinstance(value, str) or not value: - raise DeclarationError(f"{where}: 空でない文字列で書く") - path = PurePosixPath(value) - if path.is_absolute() or ".." in path.parts: - raise DeclarationError(f"{where}: リポジトリの根からの相対パスで書く(絶対パスと .. は使えない): {value!r}") - return value - - -class _StepShape(schema.Shape): - model_config = {**schema.Shape.model_config, "strict": True} - name: str - stage: Literal["production", "verification", "any"] - command: list[str] - writes: list[str] - guide: str | None = None - timeout_seconds: int = DEFAULT_TIMEOUT - - -class _DeclarationShape(schema.Shape): - model_config = {**schema.Shape.model_config, "strict": True} - version: int - steps: list[_StepShape] - - -def parse_steps(raw) -> list[Step]: - """宣言の形は lib/schema.py で見て、値の決まり(空でない・相対パス・1 以上)はここで見る。""" - if not isinstance(raw, dict): - raise DeclarationError("release.json: 最上位はオブジェクトで書く") - try: - decl = schema.load_shape(_DeclarationShape, {k: v for k, v in raw.items() if k != "$schema"}) - except schema.ShapeError as e: - raise DeclarationError(f"release.json: {e}") from None - if decl.version not in SUPPORTED_VERSIONS: - raise DeclarationError(f"version: 無いか未対応である: {decl.version!r}(読めるのは {SUPPORTED_VERSIONS})") - out = [] - for i, s in enumerate(decl.steps): - where = f"steps[{i}]" - if not s.name.strip(): - raise DeclarationError(f"{where}.name: 空でない文字列で書く(必須)") - if not s.command: - raise DeclarationError(f"{where}.command: 空でない文字列の配列で書く(必須)") - writes = [_relative(w, f"{where}.writes[{j}]") for j, w in enumerate(s.writes)] - guide = None if s.guide is None else _relative(s.guide, f"{where}.guide") - if s.timeout_seconds < 1: - raise DeclarationError(f"{where}.timeout_seconds: 1 以上の整数で書く: {s.timeout_seconds!r}") - out.append(Step(s.name, s.stage, s.command, writes, guide, s.timeout_seconds)) - return out - - -def load_steps(root: Path) -> list[Step] | None: - path = root / DECLARATION - if not path.is_file(): - return None - try: - raw = jsonio.read(path) - except jsonio.JsonReadError as e: - raise DeclarationError(f"release.json: JSON として読めない: {e.detail or e}") from e - return parse_steps(raw) - - -# ---------- 変更の判定 ---------- - - -def status_paths(root: Path) -> set[str]: - """`git status` が挙げたパス。名前の変更は元と先の両方を返す。""" - tokens = proc.git(root, "status", "--porcelain=v1", "-z", "-uall").stdout.split("\0") - paths: set[str] = set() - i = 0 - while i < len(tokens): - entry = tokens[i] - i += 1 - if len(entry) < 4: - continue - paths.add(entry[3:]) - if entry[0] in "RC" or entry[1] in "RC": - paths.add(tokens[i]) - i += 1 - return paths - - -def digests(root: Path, paths: set[str]) -> dict[str, str | None]: - """パスごとの内容の要約。無いパスは None。""" - present = sorted(p for p in paths if (root / p).is_file() or (root / p).is_symlink()) - out: dict[str, str | None] = {p: None for p in paths} - if present: - hashes = proc.run(["git", "-C", str(root), "hash-object", "--stdin-paths"], input="\n".join(present) + "\n").stdout.split() - out.update(zip(present, hashes)) - return out - - -def allowed(path: str, writes: list[str]) -> bool: - for w in writes: - prefix = w.rstrip("/") - if path == prefix or path.startswith(prefix + "/"): - return True - return False - - -# ---------- 実行 ---------- - - -def selected(steps: list[Step], stage: str) -> list[Step]: - return [s for s in steps if s.stage in (stage, "any")] - - -def expand(step: Step, version: str) -> list[str]: - return [c.replace("{version}", version) for c in step.command] - - -def changed_paths(root: Path, before_paths: set[str], before: dict[str, str | None]) -> list[str]: - """コマンドの後の状態と比べ、内容の変わったパスを返す。""" - after_paths = status_paths(root) - union = before_paths | after_paths - after = digests(root, union) - for p in after_paths - before_paths: # コマンドの前は変更が無かった = HEAD の内容 - before[p] = _head_digest(root, p) - return sorted(p for p in union if before[p] != after[p]) - - -def run_steps(root: Path, stage: str, version: str, dry_run: bool) -> int: - steps = load_steps(root) - if steps is None: - return 0 - for step in selected(steps, stage): - command = expand(step, version) - if dry_run: - step_run.print_step(step, command) - continue - try: - before_paths = status_paths(root) - before = digests(root, before_paths) - except (OSError, StepError) as e: - print(f"コマンド: {step.name} → 実行しない(git status を取れない: {e})", file=sys.stderr) - return 1 - rc = step_run.run_command(root, step, command) - if rc is None: - return 1 - changed = changed_paths(root, before_paths, before) - outside = [p for p in changed if not allowed(p, step.writes)] - print(f"コマンド: {step.name} → {rc}") - for p in changed: - print(f" 変えたパス: {p}{'(書いてよい場所の外)' if p in outside else ''}") - if rc != 0: - return 1 - if outside: - print(f"コマンド: {step.name} が書いてよい場所({', '.join(step.writes) or 'なし'})の外を変えた", file=sys.stderr) - return 1 - if step.guide: - print(f"guide: {step.guide}") - return 0 - - -def _head_digest(root: Path, path: str) -> str | None: - """コマンドの前に変更の無かったパスの要約(HEAD の内容。HEAD に無ければ None)。""" - try: - return proc.git(root, "rev-parse", "--verify", "-q", f"HEAD:{path}").stdout.strip() or None - except StepError: - return None +from release_lib.declared_steps import DECLARATION, DeclarationError, load_steps, run_steps # noqa: E402,F401 # ---------- 配布の決まった手順(#862) ---------- diff --git a/plugins/ndf/scripts/release_lib/declared_steps.py b/plugins/ndf/scripts/release_lib/declared_steps.py new file mode 100644 index 000000000..8a46e61a3 --- /dev/null +++ b/plugins/ndf/scripts/release_lib/declared_steps.py @@ -0,0 +1,199 @@ +"""配布の宣言(`.ndf/release.json`)の決まった手順の読み込みと実行(`release-steps.py` の `steps` から分けた)。 + +宣言の形は lib/schema.py で見て、値の決まり(空でない・相対パス・1 以上)は `parse_steps` が見る。コマンドの表示と起動は +release_lib/step_run.py が持つ。 +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Literal + +import jsonio +import proc +import schema +from release_lib import step_run +from step_result import StepError + +DECLARATION = ".ndf/release.json" + +SUPPORTED_VERSIONS = (1,) +STAGES = ("production", "verification", "any") +DEFAULT_TIMEOUT = 600 + + +class DeclarationError(Exception): + pass + + +@dataclass +class Step: + name: str + stage: str + command: list[str] + writes: list[str] + guide: str | None + timeout: int + + +def _relative(value, where: str) -> str: + if not isinstance(value, str) or not value: + raise DeclarationError(f"{where}: 空でない文字列で書く") + path = PurePosixPath(value) + if path.is_absolute() or ".." in path.parts: + raise DeclarationError(f"{where}: リポジトリの根からの相対パスで書く(絶対パスと .. は使えない): {value!r}") + return value + + +class _StepShape(schema.Shape): + model_config = {**schema.Shape.model_config, "strict": True} + name: str + stage: Literal["production", "verification", "any"] + command: list[str] + writes: list[str] + guide: str | None = None + timeout_seconds: int = DEFAULT_TIMEOUT + + +class _DeclarationShape(schema.Shape): + model_config = {**schema.Shape.model_config, "strict": True} + version: int + steps: list[_StepShape] + + +def parse_steps(raw) -> list[Step]: + """宣言の形は lib/schema.py で見て、値の決まり(空でない・相対パス・1 以上)はここで見る。""" + if not isinstance(raw, dict): + raise DeclarationError("release.json: 最上位はオブジェクトで書く") + try: + decl = schema.load_shape(_DeclarationShape, {k: v for k, v in raw.items() if k != "$schema"}) + except schema.ShapeError as e: + raise DeclarationError(f"release.json: {e}") from None + if decl.version not in SUPPORTED_VERSIONS: + raise DeclarationError(f"version: 無いか未対応である: {decl.version!r}(読めるのは {SUPPORTED_VERSIONS})") + out = [] + for i, s in enumerate(decl.steps): + where = f"steps[{i}]" + if not s.name.strip(): + raise DeclarationError(f"{where}.name: 空でない文字列で書く(必須)") + if not s.command: + raise DeclarationError(f"{where}.command: 空でない文字列の配列で書く(必須)") + writes = [_relative(w, f"{where}.writes[{j}]") for j, w in enumerate(s.writes)] + guide = None if s.guide is None else _relative(s.guide, f"{where}.guide") + if s.timeout_seconds < 1: + raise DeclarationError(f"{where}.timeout_seconds: 1 以上の整数で書く: {s.timeout_seconds!r}") + out.append(Step(s.name, s.stage, s.command, writes, guide, s.timeout_seconds)) + return out + + +def load_steps(root: Path) -> list[Step] | None: + path = root / DECLARATION + if not path.is_file(): + return None + try: + raw = jsonio.read(path) + except jsonio.JsonReadError as e: + raise DeclarationError(f"release.json: JSON として読めない: {e.detail or e}") from e + return parse_steps(raw) + + +# ---------- 変更の判定 ---------- + + +def status_paths(root: Path) -> set[str]: + """`git status` が挙げたパス。名前の変更は元と先の両方を返す。""" + tokens = proc.git(root, "status", "--porcelain=v1", "-z", "-uall").stdout.split("\0") + paths: set[str] = set() + i = 0 + while i < len(tokens): + entry = tokens[i] + i += 1 + if len(entry) < 4: + continue + paths.add(entry[3:]) + if entry[0] in "RC" or entry[1] in "RC": + paths.add(tokens[i]) + i += 1 + return paths + + +def digests(root: Path, paths: set[str]) -> dict[str, str | None]: + """パスごとの内容の要約。無いパスは None。""" + present = sorted(p for p in paths if (root / p).is_file() or (root / p).is_symlink()) + out: dict[str, str | None] = {p: None for p in paths} + if present: + hashes = proc.run(["git", "-C", str(root), "hash-object", "--stdin-paths"], input="\n".join(present) + "\n").stdout.split() + out.update(zip(present, hashes)) + return out + + +def allowed(path: str, writes: list[str]) -> bool: + for w in writes: + prefix = w.rstrip("/") + if path == prefix or path.startswith(prefix + "/"): + return True + return False + + +# ---------- 実行 ---------- + + +def selected(steps: list[Step], stage: str) -> list[Step]: + return [s for s in steps if s.stage in (stage, "any")] + + +def expand(step: Step, version: str) -> list[str]: + return [c.replace("{version}", version) for c in step.command] + + +def changed_paths(root: Path, before_paths: set[str], before: dict[str, str | None]) -> list[str]: + """コマンドの後の状態と比べ、内容の変わったパスを返す。""" + after_paths = status_paths(root) + union = before_paths | after_paths + after = digests(root, union) + for p in after_paths - before_paths: # コマンドの前は変更が無かった = HEAD の内容 + before[p] = _head_digest(root, p) + return sorted(p for p in union if before[p] != after[p]) + + +def run_steps(root: Path, stage: str, version: str, dry_run: bool) -> int: + steps = load_steps(root) + if steps is None: + return 0 + for step in selected(steps, stage): + command = expand(step, version) + if dry_run: + step_run.print_step(step, command) + continue + try: + before_paths = status_paths(root) + before = digests(root, before_paths) + except (OSError, StepError) as e: + print(f"コマンド: {step.name} → 実行しない(git status を取れない: {e})", file=sys.stderr) + return 1 + rc = step_run.run_command(root, step, command) + if rc is None: + return 1 + changed = changed_paths(root, before_paths, before) + outside = [p for p in changed if not allowed(p, step.writes)] + print(f"コマンド: {step.name} → {rc}") + for p in changed: + print(f" 変えたパス: {p}{'(書いてよい場所の外)' if p in outside else ''}") + if rc != 0: + return 1 + if outside: + print(f"コマンド: {step.name} が書いてよい場所({', '.join(step.writes) or 'なし'})の外を変えた", file=sys.stderr) + return 1 + if step.guide: + print(f"guide: {step.guide}") + return 0 + + +def _head_digest(root: Path, path: str) -> str | None: + """コマンドの前に変更の無かったパスの要約(HEAD の内容。HEAD に無ければ None)。""" + try: + return proc.git(root, "rev-parse", "--verify", "-q", f"HEAD:{path}").stdout.strip() or None + except StepError: + return None From 838613baa3dfb83b60633a050594f374b0d48c6f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 08:25:28 +0000 Subject: [PATCH 35/70] =?UTF-8?q?fix(contracts):=20release.json=20?= =?UTF-8?q?=E3=81=AE=E8=AA=AD=E3=81=BF=E6=89=8B=E3=82=92=20release=5Flib/d?= =?UTF-8?q?eclared=5Fsteps.py:load=5Fsteps=20=E3=81=B8=E7=A7=BB=E3=81=99?= =?UTF-8?q?=EF=BC=88#2436=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/contracts/index.md | 2 +- plugins/ndf/contracts/settings/release.json.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/contracts/index.md b/plugins/ndf/contracts/index.md index 97e04b959..9d54f244e 100644 --- a/plugins/ndf/contracts/index.md +++ b/plugins/ndf/contracts/index.md @@ -19,7 +19,7 @@ | `pace.json` | worktree | team | `plugins/ndf/scripts/lib/pace.py:_read_pace_decl`
`plugins/ndf/scripts/lib/pace.py:read_pace` | `plugins/ndf/scripts/mvv-gate.py(boundary_paths)`
`plugins/ndf/scripts/sprint-state.py(auto, enabled, verify)` | | `project.json` | worktree(delivery は main) | team | `plugins/ndf/scripts/lib/project_decl.py:read_project_decl_checked`
`plugins/ndf/scripts/lib/project_decl.py:read_project_decl` | `plugins/ndf/scripts/doc-lint.py(ndf_policies)`
`plugins/ndf/scripts/estimate.py(ndf_policies)`
`plugins/ndf/scripts/gate-rejections.py(ndf_policies)`
`plugins/ndf/scripts/hook.py(ndf_policies)`
`plugins/ndf/scripts/merged-steps.py(ndf_policies)`
`plugins/ndf/scripts/mvv-gate.py(ndf_policies)`
`plugins/ndf/scripts/parallel.py(ndf_policies)`
`plugins/ndf/scripts/project-decl.py(ndf_policies)`
`plugins/ndf/scripts/project-mvv.py(ndf_policies)`
`plugins/ndf/scripts/release-steps.py(ndf_policies)`
`plugins/ndf/scripts/release-verification-steps.py(ndf_policies)`
`plugins/ndf/scripts/sprint-state.py(ndf_policies)`
`plugins/ndf/scripts/supervise.py(ndf_policies)`
`plugins/ndf/scripts/test-run.py(ndf_policies, test)`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py(materials, ndf_policies)`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py(ndf_policies, test)`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py(ndf_policies, test)`
`plugins/ndf/skills/cross-review/scripts/drive.py(ndf_policies)`
`plugins/ndf/skills/cross-review/scripts/state.py(ndf_policies)`
`plugins/ndf/skills/fix/scripts/fix-steps.py(ndf_policies)` | | `projects.json` | worktree | team | `plugins/ndf/scripts/lib/projects-common.sh` | — | -| `release.json` | worktree | team | `plugins/ndf/scripts/release-steps.py:load_steps` | — | +| `release.json` | worktree | team | `plugins/ndf/scripts/release_lib/declared_steps.py:load_steps` | — | | `review.json` | worktree | team | `plugins/ndf/scripts/lib/review_criteria_decl.py:load` | — | | `runtimes.json` | main | team | `plugins/ndf/scripts/lib/runtime_policy.py:find` | — | | `supervise.json` | worktree | team | `plugins/ndf/scripts/supervise_lib/decl.py:read_decl`
`plugins/ndf/scripts/lib/delivery.py:_read_supervise` | `plugins/ndf/scripts/estimate.py(release)`
`plugins/ndf/scripts/hook.py(release)`
`plugins/ndf/scripts/merged-steps.py(release)`
`plugins/ndf/scripts/parallel.py(release)`
`plugins/ndf/scripts/release-steps.py(release)`
`plugins/ndf/scripts/release-verification-steps.py(release)`
`plugins/ndf/scripts/sprint-state.py(release)`
`plugins/ndf/scripts/supervise.py(release, slow, test)` | diff --git a/plugins/ndf/contracts/settings/release.json.json b/plugins/ndf/contracts/settings/release.json.json index 3fde5c1eb..488ebfd80 100644 --- a/plugins/ndf/contracts/settings/release.json.json +++ b/plugins/ndf/contracts/settings/release.json.json @@ -4,7 +4,7 @@ "place": "worktree", "scope": "team", "readers": [ - "plugins/ndf/scripts/release-steps.py:load_steps" + "plugins/ndf/scripts/release_lib/declared_steps.py:load_steps" ], "keys": { "$schema": { From 93a4bbe6e78436860f17ef10822ae1d466525293 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 08:25:09 +0000 Subject: [PATCH 36/70] =?UTF-8?q?docs(parallel):=20=E4=B8=80=E6=99=82?= =?UTF-8?q?=E3=83=AC=E3=83=BC=E3=83=B3=EF=BC=88--parallel-helper=EF=BC=89?= =?UTF-8?q?=E3=81=AE=E6=89=8B=E9=A0=86=E3=83=BB=E3=82=AA=E3=83=BC=E3=82=AC?= =?UTF-8?q?=E3=83=8A=E3=82=A4=E3=82=B6=E3=83=BC=E3=81=AE=E5=95=8F=E3=81=84?= =?UTF-8?q?=E6=96=B9=E3=83=BB=E3=83=AC=E3=83=BC=E3=83=B3=E3=81=AE=E9=85=8D?= =?UTF-8?q?=E5=B8=83=E5=BE=85=E3=81=A1=E3=81=A8=E8=B3=AA=E5=95=8F=E5=BE=85?= =?UTF-8?q?=E3=81=A1=E3=82=92=E6=9B=B8=E3=81=8F=EF=BC=88#2436=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - development-workflow の SKILL.md に --parallel-helper の分岐を足し、parallel.md に一時レーンが parallel.py helper の pipeline を流して settle か承認ゲート 2 の依頼を返す手順を書く - parallel.md にオーガナイザーの問い方(区分ごと・4 件ずつ・順・human-action・まとめた問い)と答えのコマンド、held、stop --force、異常のときの問いを書く - loop.md と確定仕様に配布待ち・質問待ち・loop finish / pending / hold-question・waiting_only・release_pending_max・I1 の例外を書く - asking.md にレーンの問いがオーガナイザーへ送られることと保留の仕方を書く - 用語集に 一時レーン・保留の周回・配布待ち・質問待ち を足す。一時レーンの承認ゲート 2 の依頼の承認資料のパスを提示のプランの present のステップから読む Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 4 + docs/glossary/glossary.json | 24 +++++ .../ndf-workflow-development-loop.md | 41 ++++++--- plugins/ndf/scripts/lib/parallel_run.py | 19 +++- plugins/ndf/scripts/lib/sprint_loop.py | 2 +- plugins/ndf/scripts/lib/sprint_loop_cli.py | 2 +- plugins/ndf/scripts/lib/sprint_loop_gate.py | 2 +- plugins/ndf/scripts/parallel.py | 4 +- .../ndf/scripts/tests/test_parallel_run.py | 2 + .../ndf/skills/development-workflow/SKILL.md | 4 + .../development-workflow/references/asking.md | 17 ++++ .../development-workflow/references/loop.md | 22 ++++- .../references/parallel.md | 90 +++++++++++++++++-- 13 files changed, 208 insertions(+), 25 deletions(-) diff --git a/docs/glossary.md b/docs/glossary.md index eda7811bc..d1a8678b1 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -366,6 +366,10 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | レーン | — | 並列の計画の下で、名前つきのラッパー 1 本が進める開発の流れ。最初の入力は開発ループかスプリントの 1 つ | — | — | `plugins/ndf/skills/issue-plan-strategy/references/parallel-plan.md` | | オーガナイザー | — | --parallel <計画名> で起動した conductor の役割。レーンを起動・監視し、配布の依頼をまとめて本番の配布を 1 本にする。統括役とは別 | — | — | `plugins/ndf/skills/development-workflow/references/parallel.md` | | 配布の依頼 | — | レーンが承認ゲート 2 の手前で、本番の配布をオーガナイザーへ頼むために送る 1 件のメッセージ(parallel.py request) | — | — | `plugins/ndf/skills/development-workflow/references/parallel.md` | +| 一時レーン | — | 並列の計画のオーガナイザーが、持ち主のいない共有の作業(まとめた配布のプランの実行・その失敗の対応・止まったレーンの保留の周回の残り)のためだけに起こすラッパー(<計画名>-rel<連番>、役割 helper)。同時に 1 本までで、作業を終えると自分で止まる | — | — | `plugins/ndf/skills/development-workflow/references/parallel.md` | +| 保留の周回 | — | 並列の計画のループのレーンで、答えを待たずに締めた周回(配布待ち・質問待ち)。課題は締め直すまで選定から外し、答えの後に残りの工程を流して 1 回だけ締め直す | — | — | `plugins/ndf/skills/development-workflow/references/loop.md` | +| 配布待ち | — | 配布の依頼を送り、答えを待たずに締めた周回の結果。まとめはまだ流していない | — | — | `plugins/ndf/skills/development-workflow/references/loop.md` | +| 質問待ち | — | 作業の途中の問いを問いの hook がオーガナイザーへ送り、止まったプランとステップを残して締めた周回の結果 | — | — | `plugins/ndf/skills/development-workflow/references/loop.md` | | 再起動の方針 | — | conductor が工程境界で会話を切るかを決める規則の組。現行方式と試行方式の 2 つ | — | — | `docs/specifications/ndf-context-restart-policy.md` | | 現行方式 | — | 運用上限 200K を超えたら次の工程を止め、4 つのカットポイントではコンテキスト量に関わらず ndf-next を出す再起動の方針。既定 | — | — | `docs/specifications/ndf-context-restart-policy.md` | | 試行方式 | — | カットポイントを再起動を判断できる境界として扱い、判断開始の値・運用上限・容量で継続か再起動かを決める再起動の方針。明示的に選んだときだけ働く | — | — | `docs/specifications/ndf-context-restart-policy.md` | diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 608091ad3..1a306dae4 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5202,6 +5202,30 @@ "meaning": "レーンが承認ゲート 2 の手前で、本番の配布をオーガナイザーへ頼むために送る 1 件のメッセージ(parallel.py request)", "source": "plugins/ndf/skills/development-workflow/references/parallel.md" }, + { + "term": "一時レーン", + "context": "ndf-workflow", + "meaning": "並列の計画のオーガナイザーが、持ち主のいない共有の作業(まとめた配布のプランの実行・その失敗の対応・止まったレーンの保留の周回の残り)のためだけに起こすラッパー(<計画名>-rel<連番>、役割 helper)。同時に 1 本までで、作業を終えると自分で止まる", + "source": "plugins/ndf/skills/development-workflow/references/parallel.md" + }, + { + "term": "保留の周回", + "context": "ndf-workflow", + "meaning": "並列の計画のループのレーンで、答えを待たずに締めた周回(配布待ち・質問待ち)。課題は締め直すまで選定から外し、答えの後に残りの工程を流して 1 回だけ締め直す", + "source": "plugins/ndf/skills/development-workflow/references/loop.md" + }, + { + "term": "配布待ち", + "context": "ndf-workflow", + "meaning": "配布の依頼を送り、答えを待たずに締めた周回の結果。まとめはまだ流していない", + "source": "plugins/ndf/skills/development-workflow/references/loop.md" + }, + { + "term": "質問待ち", + "context": "ndf-workflow", + "meaning": "作業の途中の問いを問いの hook がオーガナイザーへ送り、止まったプランとステップを残して締めた周回の結果", + "source": "plugins/ndf/skills/development-workflow/references/loop.md" + }, { "term": "再起動の方針", "context": "ndf-workflow", diff --git a/docs/specifications/ndf-workflow-development-loop.md b/docs/specifications/ndf-workflow-development-loop.md index 288a6738f..006cce80c 100644 --- a/docs/specifications/ndf-workflow-development-loop.md +++ b/docs/specifications/ndf-workflow-development-loop.md @@ -91,14 +91,14 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | # | 条件(保つこと) | 破れたときの扱い | | --- | --- | --- | -| I1 | 周回は追記だけで、番号は 1 から 1 ずつ増える。終わった周回(`outcome` が `進行中` でない)の行は書き換えない | 書き込みを拒み、記録が壊れているとして `stopped`(終了コード 1) | +| I1 | 周回は追記だけで、番号は 1 から 1 ずつ増える。終わった周回(`outcome` が `進行中` でない)の行は書き換えない。例外は保留の周回(`配布待ち`・`質問待ち`)で、答えを読んで `loop finish` か周回の境の差し戻し・失敗で 1 回だけ締め直す | 書き込みを拒み、記録が壊れているとして `stopped`(終了コード 1) | | I2 | 進行中の周回は高々 1 つ | `select` が「進行中の周回がある」で 1 | | I3 | 予算・同じ失敗・止める指示は周回の境(選定の前と周回を締めた後)でだけ判定する。周回の途中で `stop` を書くのは `gate2_returned`・`mvv_changed`・`refused` だけで、周回は進行中のまま残す | 途中の止める指示は次の境まで効かない | | I4 | 選定は入力(`rank` の出力・記録の人の判断待ちと除外・`NDF_LOOP_EXCLUDE`・`approved` の行の設計 PR の状態・ループの設定・スプリント MVV の承認)だけで決まり、同じ入力からは同じ組と並びが出る | テストで落とす | -| I5 | 答えの無い人の判断待ちの課題と、差し戻した課題は選ばない | 選定の行に理由つきで外す | +| I5 | 答えの無い人の判断待ちの課題と、差し戻した課題と、保留の周回の課題は選ばない | 選定の行に理由つきで外す | | I6 | ループは承認ゲートを自分で通さない。`by: mvv` の承認の記録を書くのは `mvv-gate.py` だけで、「従う」以外の判定を通過として扱わない | テストで落とす | | I7 | 止まったループは、`loop start` が止まった理由を判定し直して解けたと確かめるまで、周回を始めない | `select` が止まったままの結果(3)を返す | -| I8 | 予算の消費(周回の数・費用・動いていた時間)は再開をまたいで引き継ぐ。動いていた時間は、終わった周回の `started_at`〜`ended_at` の和から、`stops` の `at`〜`resumed_at` のうち各周回の区間と重なる部分を引いたもの。周回の境で止まっていた時間は引かない | テストで落とす | +| I8 | 予算の消費(周回の数・費用・動いていた時間)は再開をまたいで引き継ぐ。動いていた時間は、終わった周回の `started_at`〜`ended_at` の和から、`stops` の `at`〜`resumed_at` のうち各周回の区間と重なる部分を引いたもの。周回の境で止まっていた時間は引かない。保留の周回は締めるまで(`held_at`)の区間と、答えの後に残りを流した区間(`release.finish` か `question.finish`)を足し、答えを待つ時間は数えない | テストで落とす | | I9 | 周回を始めるときと承認ゲートで止まったときのプロジェクト MVV の sha256 は、ループの開始時の値と一致し、承認済みである | `mvv_changed` で止まる | ### 起動の引数と再開のコマンド @@ -121,14 +121,14 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 ### 設定(`.ndf/pace.json` の `loop` 節) ```json -{"loop": {"enabled": false, "budget": {"laps": 3, "usd": 60, "hours": 24}, "same_failure": 2, "max_issues": 3}} +{"loop": {"enabled": false, "budget": {"laps": 3, "usd": 60, "hours": 24}, "same_failure": 2, "max_issues": 3, "release_pending_max": 2}} ``` | 項目 | 形 | 無いとき | 誤っているとき | | --- | --- | --- | --- | | `loop.enabled` | `true` だけが真 | 偽(ループを断る) | — | | `loop.budget` | オブジェクト。`laps`・`usd`・`hours` の正の数(bool は不可)。欠けたキーは既定で埋め、ほかのキーは不可 | 3 / 60 / 24 | `PaceError` | -| `loop.same_failure` / `loop.max_issues` | 1 以上の整数(bool は不可) | 2 / 3 | `PaceError` | +| `loop.same_failure` / `loop.max_issues` / `loop.release_pending_max` | 1 以上の整数(bool は不可)。`release_pending_max` は答えの無い保留の周回(配布待ち・質問待ち)を合わせた上限 | 2 / 3 / 2 | `PaceError` | `PaceError` は `loop start` だけを断る。`read_pace` を使う `fast` / `auto` / `check-trigger` / `mvv-gate` は節の誤りの 影響を受けない。 @@ -142,11 +142,14 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | 副命令 | 主な入力 | 0 のときの `metrics` | 1 / 3 | | --- | --- | --- | --- | | `start [<名>] [--milestones] [--budget-*] [--no-goal]` | `loop` 節・`auto` 節・プロジェクト MVV・記録 | `status`(`new` / `resumed` / `in_lap`)・`next`(`in_lap` は引継ぎ文書の「次にやること」を指す。`resumed` は `loop select` か、止まった理由ごとの続き) | 1 = 使ってよい条件に当たらない(`items` に理由、`next` に `/ndf:development-workflow #<課題>`。記録を作らない)/ 3 = 止まった理由が解けていない | -| `select <名>` | `upkeep.py rank`(その場で打つ)・マイルストーンの説明・記録 | `status: selected`・`selected`・`excluded`・`milestone`・`rank_digest`(候補 0 件で見直しを挟むときは `status: review_first`・`next`) | 1 = 進行中の周回がある・`rank` が 0 と 20 以外を返した / 3 = 止まったまま・境の判定で止まった・`no_issue` / `outside_mvv` | +| `select <名>` | `upkeep.py rank`(その場で打つ)・マイルストーンの説明・記録・並列の計画の依頼の答え(待たずに読む) | `status: selected`・`selected`・`excluded`・`milestone`・`rank_digest`(候補 0 件で見直しを挟むときは `status: review_first`・`next`。答えの届いた保留の周回があれば `status: finish`・`lap`・`pipeline`) | 1 = 進行中の周回がある・`rank` が 0 と 20 以外を返した / 3 = 止まったまま・境の判定で止まった・`no_issue` / `outside_mvv` / `waiting_only` | | `exclude <名> <課題> --reason <文>` | 選定の後・`sprint` の前の進行中の周回 | `select` と同じ形(選定をやり直す) | 1 = スプリントを組む前の周回が無い / 3 = すべて外れた(その周回を取り下げ、除外の行は残す) | | `sprint <名> --mode M --dev V [--prod V] [-- ]` | 進行中の周回の選定・前の周回の `refine.deferred` | `status: sprint`・`state_path`・`out`・`manifest`・`close_command`・`queue`・`pipeline` | 1 = 進行中の周回が無い・承認ゲート 1 の後の周回 / 3 = `init`・`new sprint`・`new close` のどれかが断った(`refused`) | | `gate <名> --done ` | 承認ゲートで止まった done・`mvv-gate.jsonl`・スプリント状態 | `status: continue`・`gate: 1`・`waiting`・`pipeline`(残りで続く)、または周回を締めて `ndf_next` | 1 = 組んだ周回が無い・done に関門が無い / 3 = `gate2_returned`・`mvv_changed`・続きを組めない(`refused`)・締めた後の境の判定 | -| `answer <名> <課題> --approved\|--rejected [--pr N]` | 答えの無い人の判断待ちの行 | `status: answered` | 1 = 待っていない課題 | +| `answer <名> <課題> --approved\|--rejected\|--revise --note [--pr N]` | 答えの無い人の判断待ちの行 | `status: answered`(`--revise` は行に `note` と `revise_count` を書く) | 1 = 待っていない課題・指摘ファイルを読めない | +| `finish <名> <周回> --done ` | 保留の周回・残りの工程の done | `lap` と同じ(周回を `完了` か `失敗` で締め直す。質問待ちの続きが配布の依頼で止まれば `配布待ち` へ移す) | 1 = 保留の周回でない(締め直しは 1 回だけ) / 3 = 境の判定で止まった | +| `pending <名>` | 記録・依頼の答え | `status: finish`・`lap`・`pipeline`(答えの届いた最も古い保留の周回の残り)か `status: none` | — | +| `hold-question <名> --ref <依頼> --plan

--step [--parallel <計画名>]` | 進行中の周回 | `lap` と同じ(周回を `質問待ち` で締めて次の周回へ) | 1 = 進行中の周回が無い / 3 = 境の判定で止まった | | `approve-mvv <名> --milestone M` | マイルストーンの説明 | `status: approved`(`sprint_mvv_approvals` に `{milestone, sha256, at, by: user}`) | 3 = 説明に `## Mission` / `## Vision` / `## Value` がそろわない | | `lap <名> [--failed] [--done D] [--deferred-issues N...]` | スプリント状態(`update` を内で打つ)・done・upkeep の記録 | `status: continue`・`ndf_next`(`ndf-next` のブロック) | done に関門があれば `gate` と同じ / 1 = 進行中の周回が無い / 3 = 境の判定で止まった | | `stop <名>` | — | `path`(`.ndf/loop/<名>/stop` に時刻を書く) | — | @@ -219,6 +222,7 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | 同上で残りが無い | 周回を `人へ戻した`(設計が完了しなかった課題があれば `失敗`)で締め、境の判定へ進む | | 同上で続きを組めない | `refused`(`step: 続きの queue`)で止まる | | 承認ゲート 1 以外(承認ゲート 2) | 判定の写しを周回へ足し、止まった計画を `gate2_plan` に控え、`gate2_returned`(`values: {pr, verdict, reasons}`)で止まる。本番は出さない | +| 配布の依頼のプラン(フェーズ `配布の依頼`。並列の計画のレーン) | 承認ゲート 2 として止まらず、周回を `配布待ち` で締める。`release` に依頼のプラン・依頼の記録(`work/request.txt`)・依頼の id・計画名・同じ queue の残りのステージ・まとめのプラン(`close_plans`)を残し、まとめは流さない。境の判定へ進む | 承認ゲート 2 の答えは、止まった時刻より後の、周回の状態の `関門 2` の利用者の記録(`by` が `mvv` でない)と `gate-rejections.jsonl` の差し戻しのうち最新のもので決める。 @@ -237,7 +241,16 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | 3 | `same_failure` | 終わった周回を新しい順に見て、`失敗` の周回が同じ `failure` の鍵で `same_failure` 回以上続く。`完了` の周回で数え直す。`same_failure` で止まった後の最後の再開(`resumed_at`)より前に始まった周回は数えない。ほかの理由の再開は区切りにしない | `{key, count}` | | 4 | `budget_laps` / `budget_usd` / `budget_hours` | 終わった周回の数・`cost` の和・動いていた時間(I8)が予算以上 | `{consumed, budget}` | -選定の時点では `no_issue` / `outside_mvv`、周回の途中では `gate2_returned` / `mvv_changed` / `refused` +**保留の周回(並列の計画のレーン)があるときの境の順。** `user_stop`・`mvv_changed` はすぐ止まる。次に依頼の答えを古い順に読む: +オーガナイザーへ送った設計の承認の答え(承認ならラベル `design-approved` と `loop answer --approved` と同じ記録、直して出し直すなら指摘、 +やらないなら差し戻し)→ `配布待ち` の `rejected`(`人へ戻した` で締め、`fixes` に課題と理由を足し、次の周回で先頭に選ぶ)・`failed` +(`失敗` で締め、鍵は `{issues, kind: 配布, step: 配布の失敗}`)→ 最も古い `released` の `配布待ち` か答えの届いた `質問待ち` +(`status: finish` と残りの `pipeline` を返し、新しい周回を始めない)。次の配布へ回した知らせ(`defer-<依頼>`)だけなら `配布待ち` のまま。 +その後、答えの無い保留の周回が `release_pending_max` 以上あれば `waiting_only`、3・4 に当たって答えの無い保留の周回があれば +先に `waiting_only` で止まる。選べる課題が無く、保留の周回かオーガナイザーへ送った答えの無い設計の承認があれば、`no_issue` / +`outside_mvv` と見直しの代わりに `waiting_only` で止まる(`values: {why, pending, waiting}`)。 + +選定の時点では `no_issue` / `outside_mvv` / `waiting_only`、周回の途中では `gate2_returned` / `mvv_changed` / `refused` (`{step, exit, summary}`)が加わる。`stop` は `{condition, values, lap, at}` の形で、解けたら `resumed_at` を足して `stops` へ移す。 @@ -257,6 +270,7 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | `refused`(続きの queue) | 人の判断待ちへ移した課題を戻さず、残した課題で続きを組み直す。組めなければ 3 | | `refused`(ほか) | `next` に同じ周回での `loop sprint` のやり直しを載せる(モードと版は conductor が決め直す) | | `mvv_changed` | 解けない。新しい名前でループを始め直すよう示す(開始時の値を今の値へ置き換えない) | +| `waiting_only` | 再開そのもの(答えを送ったオーガナイザーが `relay.py restart` で起こす)で解け、次の `select` が答えを読み直す | ### ループの記録(データ) @@ -279,10 +293,11 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | `name` / `repo` / `started_at` / `goal` | 名前・`OWNER/REPO`・開始の時刻(再開で変えない)・`/goal` で始めたか | | `project_mvv` | 開始時の承認済みプロジェクト MVV(`{version, sha256}`) | | `budget` / `budget_history` | 効いている予算と上書きの履歴 | -| `same_failure` / `max_issues` / `milestones` | 開始時に設定と引数から写した値 | +| `same_failure` / `max_issues` / `release_pending_max` / `milestones` | 開始時に設定と引数から写した値 | | `status` / `stop` / `stops` | `running` / `stopped`・今の止まった理由・過去の止まった理由(追記だけ) | | `laps[]` | 周回(追記だけ。I1) | -| `waiting[]` | 人の判断待ち。答えは行を消さずに `answer`・`answered_at` を足す。`approved` の行はマージを確かめた時刻 `merged_at` を足す | +| `waiting[]` | 人の判断待ち。答えは行を消さずに `answer`(`approved` / `rejected` / `revise`)・`answered_at` を足す。`approved` の行はマージを確かめた時刻 `merged_at` を足す。並列の計画のレーンは、オーガナイザーへ送った設計の承認の依頼の `request` と計画名 `plan` を持つ。`revise` の行は指摘 `note` と回数 `revise_count` を持つ | +| `fixes[]` | 配布を差し戻された周回の直し `{lap, issues, reason, at}`。直しの周回で課題を選ぶと `fixed_by` に周回の番号を書く | | `excluded[]` | `{number, reason, lap}`(`loop exclude`) | | `sprint_mvv_approvals[]` | `{milestone, sha256, at, by}` | @@ -298,7 +313,11 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | `retro` / `refine` | 振り返りの置き場(まとめのプランの報告)・棚卸しの結果 `{report, rank_changes, deferred[]}`(upkeep の記録の `candidates.json`・`rank.json` から) | | `created[]` / `deferred_issues[]` | 周回の中で起票された課題(その周回の選定に入れない)・上限で後へ回した Issue(`lap --deferred-issues`) | | `cost` / `seconds` | スプリント状態の計画の行の和 | -| `outcome` / `failure` | `進行中` / `完了` / `失敗` / `人へ戻した`・失敗の鍵 | +| `outcome` / `failure` | `進行中` / `完了` / `失敗` / `人へ戻した` / `配布待ち` / `質問待ち`・失敗の鍵 | +| `close_plans` | `loop sprint` が組んだまとめのプラン(保留の周回は答えの後に流す) | +| `release` | 配布待ちの周回: `plan`・`out`・`request`・`plan_name`・`stages`(同じ queue の残り)・`close_plans`・`answer`(届いた答え)・`finish`(`started_at`・`ended_at`・`done`・`pipeline`) | +| `question` | 質問待ちの周回: `request`・`plan`・`step`・`plan_name`・`finish` | +| `held_at` | 保留の周回を最初に締めた時刻(動いていた時間の区切り。I8) | | `relay_dir` / `started_at` / `ended_at` | 締めたセッションの `NDF_RELAY_DIR`・周回の開始と終わり | **周回はスプリント状態を `state_path` で参照し、締めるときに費用・秒・承認ゲート・版を写して固定する。** 以後スプリント状態が diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 00896325e..964c6a025 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -446,6 +446,23 @@ def helper_spawn(name: str, n: int, k: int, pending: Optional[str] = None) -> st return f"python3 {RELAY} spawn --name {pr.helper_name(name, n)} --role helper --plan {name} --json -- '{first}'" +def present_approval(d: Path) -> str: + """提示のプランの承認資料の絶対パス(ステップ `present` の `--approval` と `cwd` から読む)。読めなければ空。""" + import re + import shlex + + try: + steps = json.loads((Path(d) / "present.json").read_text(encoding="utf-8")).get("steps") or [] + except (OSError, ValueError): + return "" + step = next((x for x in steps if isinstance(x, dict) and x.get("id") == "present"), {}) + m = re.search(r"--approval (\S+)", step.get("cmd") or "") + if not m: + return "" + path = Path(shlex.split(m.group(1))[0]) + return shlex.quote(str(path if path.is_absolute() else Path(step.get("cwd") or ".") / path)) + + def helper_task(main: Path, name: str, n: int, k: int) -> dict: """一時レーン(`--parallel-helper <計画名> <連番> <提示>`)が流すもの。最後の起動の指示(イベント `pause` か、 配布の途中の知らせに添えた `release`)が同じ連番と提示の `confirmed` なら確定の後の 3 手、そうでなければ提示。 @@ -458,7 +475,7 @@ def helper_task(main: Path, name: str, n: int, k: int) -> dict: launch = ev if ev.get("action") in ("release", "confirmed") else ev.get("release") or {} d = pr.release_dir(main, name, n, k) py = f"python3 {HERE / 'parallel.py'}" - out = {"n": n, "k": k, "dir": str(d), "gate2": f"{py} request-gate2 {name} {n} {k} --report {d / 'present-state' / 'report.md'}"} + out = {"n": n, "k": k, "dir": str(d), "gate2": f"{py} request-gate2 {name} {n} {k} --report {present_approval(d)}"} if launch.get("action") == "confirmed" and launch.get("n") == n and launch.get("k") == k: done = d / "release-done.json" gate = f"python3 {HERE / 'sprint-state.py'} gate {d / 'answer.json'} '関門 2' --what 'まとめた配布 {n} の提示 {k}' --by user --outcome approved" diff --git a/plugins/ndf/scripts/lib/sprint_loop.py b/plugins/ndf/scripts/lib/sprint_loop.py index 43c950ab2..15fa0ee34 100644 --- a/plugins/ndf/scripts/lib/sprint_loop.py +++ b/plugins/ndf/scripts/lib/sprint_loop.py @@ -371,7 +371,7 @@ def cmd_answer( try: text = Path(note or "").read_text(encoding="utf-8").strip() except OSError as e: - return refusal_order(f"指摘のファイルを読めない: {e}") + return refusal_order(f"指摘ファイルを読めない: {e}") notes = apply_answer(env, rec, w, "revise" if revise else ("approved" if approved else "rejected"), pr, text if revise else None) write_record(env, rec, before) if revise: diff --git a/plugins/ndf/scripts/lib/sprint_loop_cli.py b/plugins/ndf/scripts/lib/sprint_loop_cli.py index 4c20a4590..571517a1f 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_cli.py +++ b/plugins/ndf/scripts/lib/sprint_loop_cli.py @@ -39,7 +39,7 @@ def run_loop_command(a, update) -> dict | None: "list": lambda: sl.cmd_list(env, a.loop_name), } if a.loop_cmd == "answer" and a.revise and not a.note: - return step_result.result(sl.TOOL, "stopped", "--revise には --note <指摘のファイル> が要る", [], {"status": "refused"}) + return step_result.result(sl.TOOL, "stopped", "--revise には --note <指摘ファイル> が要る", [], {"status": "refused"}) out = handlers[a.loop_cmd]() if a.loop_cmd in ("status", "list") and not a.json: print( diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index 41be6195f..38a4d1ef1 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -202,7 +202,7 @@ def gate_copy(name: str, row: dict) -> dict: def waiting_section(rec: dict, w: dict) -> str: - how = f"python3 {SPRINT_STATE} loop answer {rec['name']} {w['number']} --approved|--rejected|--revise --note <指摘のファイル>" + how = f"python3 {SPRINT_STATE} loop answer {rec['name']} {w['number']} --approved|--rejected|--revise --note <指摘ファイル>" pr = f"#{w['pr']}" if w.get("pr") else "—" lines = [ f"- 判定: {w.get('verdict', 'unknown')}(承認ゲート {w['gate']})", diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index ec5ed98ea..fe40f4871 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -388,11 +388,11 @@ def cmd_answer(a) -> None: note = None if a.revise: if not a.note: - reply("stopped", "--revise には --note <指摘のファイル> が要る", code=1) + reply("stopped", "--revise には --note <指摘ファイル> が要る", code=1) try: note = Path(a.note).read_text(encoding="utf-8").strip() except OSError as exc: - reply("stopped", f"指摘のファイルを読めない: {exc}", code=2) + reply("stopped", f"指摘ファイルを読めない: {exc}", code=2) result = "approved" if a.approved else "revise" if a.revise else "rejected" if a.rejected else "answered" nxt = _owner_restart(main, a.name, a.id) pl.send_answer(main, a.name, a.id, result, note=note, choice=a.choice) diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index 79d1245ea..bc3f9fcec 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -404,6 +404,8 @@ def test_helper_task_follows_the_last_launch(env, tmp_path): d = prl.release_dir(main, "p1", 1, 1) assert task["phase"] == "present" and f"queue {d / 'present.json'}" in task["pipeline"] assert "request-gate2 p1 1 1" in task["gate2"] and "settle p1 1 --done" in task["settle"] + (d / "present.json").write_text(json.dumps({"steps": [{"id": "present", "cwd": "/r", "cmd": "parallel.py present p1 1 1 --approval issues/a.md && cat x"}]})) + assert prun.helper_task(main, "p1", 1, 1)["gate2"].endswith("--report /r/issues/a.md") (d / "presented.json").write_text(json.dumps({"sha": "sha1", "prs": [10]})) pl.request_gate2(main, "p1", 1, 1, str(d / "report.md")) item = until(lambda: [i for i in questions(main)["items"] if i["kind"] == "gate2"])[0] diff --git a/plugins/ndf/skills/development-workflow/SKILL.md b/plugins/ndf/skills/development-workflow/SKILL.md index 1d98511f8..fc8a694b6 100644 --- a/plugins/ndf/skills/development-workflow/SKILL.md +++ b/plugins/ndf/skills/development-workflow/SKILL.md @@ -225,6 +225,10 @@ pace: fast このセッションはオーガナイザーになり、並列の計画のレーンを起動・監視し、本番の配布を 1 本にまとめる。 並列の計画の作り方は [issue-plan-strategy の parallel-plan.md](../issue-plan-strategy/references/parallel-plan.md) にある。 +**引数に `--parallel-helper <計画名> <連番> <提示>` か `--parallel-helper <計画名> pending <ループ名>` があれば、このセッションは +オーガナイザーが起こした一時レーンである。** 判定の手順へ進まず、[references/parallel.md](references/parallel.md) の「一時レーン」の手順で +`parallel.py helper` が返す pipeline(まとめた配布の提示か確定の後)か `loop pending` の pipeline を流し、結果を返して終える。 + ## 開発ループ(`--loop`) **引数に `--loop [<ループ名>]` があれば、判定の手順へ進まず [references/loop.md](references/loop.md) の手順で進める。** diff --git a/plugins/ndf/skills/development-workflow/references/asking.md b/plugins/ndf/skills/development-workflow/references/asking.md index 2203eaa06..9df4622d8 100644 --- a/plugins/ndf/skills/development-workflow/references/asking.md +++ b/plugins/ndf/skills/development-workflow/references/asking.md @@ -54,6 +54,23 @@ python3 "${CLAUDE_PLUGIN_ROOT}/scripts/ask.py" decide --ref --answer "<選 手順が「AI が決める」とした場面(hook に止められる前に決めた場面)は、`--ref` の代わりに `--question` で問いを書く。 +## 並列の計画のレーン + +並列の計画([parallel.md](parallel.md))の下で区分つきの問いを出すのはオーガナイザーだけである。レーン(`NDF_RELAY_NAME` が計画のレーンにある) +と一時レーン(作業ディレクトリの役割のファイルが `helper`)が、hook の通す問い(承認ゲート・レッドライン・許された区分)を出すと、 +hook が `parallel.py request-ask` でオーガナイザーへ送り、問いを止めて保留の手順を返す。止める問い(区分が無い・許されていない)は今どおり止まり、送らない。 +送れないとき(計画が動いていない・送り手でない・上限を越えた)は今どおり問いを通す。 + +送られたら、その作業を保留して別の作業へ進む。答えはオーガナイザーが送り、持ち主が読んで続ける。 + +| 送り手 | 保留の仕方 | 答えが届いたとき | +| --- | --- | --- | +| 開発ループのレーン | `sprint-state.py loop hold-question <ループ> --ref <依頼> --plan <止まったプラン> --step <続きのステップ>` で周回を「質問待ち」で締め、次の周回へ進む([loop.md](loop.md)) | 周回の境で、止まったプランを続きのステップから流す | +| 1 スプリントのレーン・一時レーン | 引継ぎ文書の「次にやること」に続きのコマンドと依頼の id を書き、`ndf-next` を出さずにセッションを終える | オーガナイザーが `relay.py restart <レーン>` で起こす。`parallel.py await --from` か答えのイベントを読み、続きから進む | + +設計の承認(承認ゲート 1)と本番の配布(承認ゲート 2)は、レーンが依頼として送るため問わない。ループのレーンは承認ゲート 1 で +課題を人の判断待ちへ移すときに設計の承認の依頼を送り、本番のステージは配布の依頼のプランになる。 + ## 問い合わせの宣言 `.ndf/ask.json` プロジェクトごとに hook の働き方と人へ回す区分を変える。NDF はこのファイルを読むだけで、書かない。 diff --git a/plugins/ndf/skills/development-workflow/references/loop.md b/plugins/ndf/skills/development-workflow/references/loop.md index e0479823e..8d9653aab 100644 --- a/plugins/ndf/skills/development-workflow/references/loop.md +++ b/plugins/ndf/skills/development-workflow/references/loop.md @@ -45,6 +45,7 @@ | 3 | `$SS loop sprint <名> --mode <モード> --dev <開発版> [--prod <本番の版>] [-- ]` | 判断は無い。返った `metrics.pipeline` をそのまま流す | | 4 | `metrics.pipeline`(`supervise.py queue ... --done ` → `wait ` → `loop lap <名> --done `)。`wait` と並べて `metrics.research`(待ち時間の調査)を背景で打つ | `wait` が attention を返したら、今の `supervise.py` の手順で扱う。調査の結果は読まない(見直しが読む) | | 5 | `loop lap` の結果 | 0 で `metrics.ndf_next` があれば、それを出してセッションを切る。0 で `metrics.pipeline` があれば(承認ゲート 1 で課題を外した続き)それを流す。3 なら下の「止まったとき」 | +| 2' | `loop select` が `status: finish` を返した | 答えの届いた保留の周回(`metrics.lap`)の残りの工程。新しい周回を始めず、`metrics.pipeline`(残りの queue → `wait` → `loop finish <名> <周回> --done `)をそのまま流す。`loop finish` の結果は手順 5 と同じに扱う | **品質を上げる工程は今の上限で打ち切る。** クロスレビュー・クロスリファクタリング・検査が上限(ラウンド・時間の予算・ `timeout`)に達して残した指摘は、その周回で続けずに `out-of-scope` で Issue に起こし(`.ndf/backlog.json` の @@ -65,11 +66,14 @@ | 承認ゲート 1(設計のプラン)で、判定が「従う」以外・レッドライン・判定を読めない | その課題を人の判断待ちへ移し、残りの課題で続ける。残りが無ければ周回を「人へ戻した」で締めて次の周回へ進む。利用者へ問わない | | 承認ゲート 1 で、プロジェクト MVV が開始時と違う | `mvv_changed` で止まる | | 承認ゲート 2(本番のプラン) | `gate2_returned` で止まる。今の承認ゲートの出し方(`AskUserQuestion`。`metadata.source` は `ndf-gate:2`)で利用者の承認を求める | +| 配布の依頼(並列の計画のレーン。フェーズ「配布の依頼」のプラン) | 承認ゲート 2 として止まらず、`loop lap` が周回を「配布待ち」で締めて次の周回へ進む(下の「並列の計画のレーン」) | **人の判断待ちの課題には利用者が答える。** `$SS loop answer <名> <課題> --approved [--pr <設計 PR>]` は承認の記録を書き、 設計 PR がマージされた後の周回で選定の対象に戻す(その課題は設計のステージを置かない)。`--rejected` は差し戻しの記録を書き、 以後選ばない。答えるまで、その課題は選ばない。 +`--revise --note <指摘ファイル>` は「直して出し直す」で、指摘を課題の本文の節に書き、次の周回で順位によらず先頭に選ぶ。 + **スプリント MVV を持つマイルストーンは、利用者が承認したときだけ選ぶ。** 承認は `$SS loop approve-mvv <名> --milestone <題>` で、説明の Mission / Vision / Value の sha256 を記録する。説明が変われば承認し直す。 @@ -84,6 +88,7 @@ | 3 | `same_failure` | 直近の失敗の周回が同じ鍵で `loop.same_failure` 回続く(完了の周回で数え直す) | 再開の起動で解け、数え直す | | 4 | `budget_laps` / `budget_usd` / `budget_hours` | 終わった周回の数・費用の和・動いていた時間が予算以上 | `--budget-*` で上書きした予算で判定し直す | | 5 | `no_issue` / `outside_mvv` | 選定の候補が 0 件 / 候補がすべて外れた。`no_issue` は止まる前に 1 回だけ見直しを挟む(`loop select` が `status: review_first` と `metrics.next` を返す。見直しの後も 0 件なら止まる) | 再開で選定をやり直す | +| — | `waiting_only` | 並列の計画のレーンで、答えの無い保留の周回が `loop.release_pending_max`(既定 2)に達した・選べる課題が無く答えを待つ作業だけが残る・3〜5 に当たったが答えの無い保留の周回がある | 再開(答えを送ったオーガナイザーが `relay.py restart` で起こす)の境で答えを読み直す | 周回の途中で止まるのは `gate2_returned`・`mvv_changed`・`refused`(`new sprint` などが断った)だけで、周回は進行中のまま残る。 `gate2_returned` は利用者が承認すれば `loop start` の `metrics.next` の `pipeline`(承認ゲート 2 で止まった計画を経路に合う @@ -92,6 +97,21 @@ 止まっていた時間は経過時間に入れない。周回の数・費用・経過時間は再開をまたいで引き継ぐ。 +## 並列の計画のレーン + +並列の計画([parallel.md](parallel.md))の下のループは、本番を自分で出さず、問いでも止まらない。答えを待つ周回は「保留の周回」として締め、 +答えを待たずに次の周回へ進む。 + +| 周回の結果 | 締める時 | 答えの後 | +| --- | --- | --- | +| `配布待ち` | 配布の依頼のプランが依頼を送って承認ゲートとして終わり、`loop lap` に渡ったとき。まとめは流さずに記録へ残す | `released` ならまとめを流して `完了` か `失敗` で締め直し、版を書く。`rejected` は `人へ戻した` で締め、次の周回で同じ課題を先頭に選んで直す。`failed` は `失敗` で締める。次の配布へ回した知らせだけなら配布待ちのまま | +| `質問待ち` | 作業の途中の問いを問いの hook がオーガナイザーへ送り、conductor が `$SS loop hold-question <名> --ref <依頼> --plan <止まったプラン> --step <続きのステップ>` を打ったとき | 止まったプランを続きのステップから流し、同じ queue の残りとまとめを流して締め直す | + +- 保留の周回の課題は、締め直すまで選定から外す(「保留の周回の課題」)。保留の周回は予算の周回の数に入り、同じ失敗には数えない +- 周回の境の `loop select` は、答えを古い順に読む。オーガナイザーへ送った設計の承認の答えも読み、承認ならラベル `design-approved` と + 承認の記録、直して出し直すなら指摘、やらないなら差し戻しを、レーンが自分で書く +- 止まったレーンの答えを受けた保留の周回は、オーガナイザーが起こす一時レーンが `$SS loop pending <名>` の `pipeline` で流す + ## 止まったとき **`ndf-next` のブロックを出さない。** 出すとラッパーが次のセッションを起動し、同じ理由で止まり直す。 @@ -110,7 +130,7 @@ | 置き場 | 中身 | | --- | --- | -| `.ndf/loop/<名>/loop.json` | 正本。周回(選定と理由・承認ゲートの判定の写し・版・振り返りの置き場・棚卸しの持ち越しと順位の変化・棚卸しの深さの行(`depths`)・待ち時間の調査の項目と費用と秒(`research`。予算に数える)・費用と秒・後へ回した Issue・結果)・人の判断待ち・除外・止まった理由 | +| `.ndf/loop/<名>/loop.json` | 正本。周回(選定と理由・承認ゲートの判定の写し・版・振り返りの置き場・棚卸しの持ち越しと順位の変化・棚卸しの深さの行(`depths`)・待ち時間の調査の項目と費用と秒(`research`。予算に数える)・費用と秒・後へ回した Issue・結果・配布待ちの `release`(依頼のプラン・依頼の id・計画名・残りのステージとまとめのプラン・届いた答え・残りを流した区間)・質問待ちの `question`(依頼の id・止まったプランとステップ))・人の判断待ち(オーガナイザーへ送った依頼の id と、直して出し直す指摘と回数を含む)・除外・配布の差し戻しの直し(`fixes`)・止まった理由 | | `.ndf/loop/<名>/laps.md` | 人が読む描画。書き換えるたびに作り直す | | `.ndf/handoff/loop-<名>.md` | 引継ぎ文書。現在地と次に実行するコマンドだけ | diff --git a/plugins/ndf/skills/development-workflow/references/parallel.md b/plugins/ndf/skills/development-workflow/references/parallel.md index 8fa6beda8..ea66ae824 100644 --- a/plugins/ndf/skills/development-workflow/references/parallel.md +++ b/plugins/ndf/skills/development-workflow/references/parallel.md @@ -22,15 +22,42 @@ | `wait` が返したもの | すること | | --- | --- | | `notice` | 承認待ちのレーンの `metrics.attach` の 1 行を出す。`metrics.release` があり、その `seq` の起動をまだ打っていなければ、`metrics.release.spawn` の 1 行も打つ(知らせが重なって `release` / `confirmed` を読み逃しても起動を失わない) | -| `questions` | 届いている依頼(設計の承認・承認ゲート 2・承認ゲートの外の問い)を区分ごとにまとめて 1 回で問う | -| `release` / `confirmed` | `metrics.spawn` の 1 行(一時レーンの起動)を打つ。一時レーンが `metrics.pipeline` を流し、結果を返す | +| `questions` | 届いている依頼(`items`)を下の「問い方」で問い、依頼ごとに答えを送る | +| `release` / `confirmed` | `metrics.spawn` の 1 行(一時レーンの起動)を打つ。一時レーンが配布のプランを流し、結果を返す | +| `held` | `metrics.spawn` の 1 行を打つ。一時レーンが、止まったループのレーンの答えを受けた保留の周回の残りを流す(終える前の後片付け) | | `finished` / `stopped` | レーンごとの結果(配布した版・止まった理由)を要約し、`[ndf-parallel] finished p1` の行を出して終える | | 終了コード 3 | 同じ `wait` を打ち直す | +### 問い方 + +並列の計画の下で区分つきの問いを出すのはオーガナイザーだけである。レーンと一時レーンの問いは依頼として `items` に届く。 + +1. 区分ごとに `AskUserQuestion` を呼ぶ(`metadata.source` は呼び出しごとに 1 つ)。順は `ndf-gate:1` → `ndf-ask:redline:*` → + ほかの `ndf-ask:*` → `ndf-gate:2`。承認ゲート 2 を最後にするのは、答えの時点で `develop` の先頭を比べるためである +2. 依頼 1 件を問い 1 つにし、5 件以上の区分は届いた順に 4 件ずつ続けて呼ぶ。各問いにはレーン名・課題・設計 PR の URL か作業の場所を添える +3. `ndf-ask:human-action` の依頼は `attach`(`relay.py attach <レーン>`)の 1 行を添えて「済んだ」「まだ」で問う。 + `merged` があれば、同じ原因でまとめた各レーンの元の文面を並べる(答えは全レーンへ届く) +4. 耐久ワークフローが自分で立てる問い(`kind` が `stall`・`helper`・`spawn`)は `options` で問い、選んだ答えの `commands` の 1 行があれば打つ +5. 答えを送り、`wait` を打ち直す。答えの間に新しい依頼が届いていれば `questions` が続けて返る + +| 依頼の `kind` | 問いの答え | 送るコマンド | +| --- | --- | --- | +| `gate1`(設計の承認) | 承認 / 直して出し直す(指摘を書く) / やらない | `parallel.py answer p1 --approved` / `--revise --note <指摘ファイル>` / `--rejected` | +| `gate2`(承認ゲート 2) | 下の「承認ゲート 2」 | `confirm` / `settle --rejected` | +| `ask`・`stall`・`helper`・`spawn` | 依頼の選択肢 | `parallel.py answer p1 --choice <選んだ答え>` | + +`answer` は答えのイベントを立てるだけで、ラベル・記録・直しは持ち主のレーンが周回の境で行う。持ち主のレーンが止まっていれば +`metrics.next` に `relay.py restart <レーン>` の 1 行が返るので、それを打つ。 + 承認ゲート 2 は、提示 1 なら「承認」「差し戻す」で問い、`parallel.py confirm p1 <連番>` か `parallel.py settle p1 <連番> --rejected` を打つ。出し直しの提示(`represent: true`)なら加わった範囲を添えて 「新しい範囲で出す」「最初の範囲だけ出す」「差し戻す」で問い、`confirm --take new` / `--take first` / `settle --rejected` を打つ。 +### セッションの切り替え + +カットポイントでは、問いを出していなければ引継ぎ文書 `.ndf/handoff/parallel-p1.md` の「次に実行するコマンド」に起動と同じ 1 行を書き、 +`ndf-next` を出して切る。新しいセッションの `start` は動いている `run` をそのまま使い、`wait --after 0` が答えていない問いを返す。 + ## まとめた配布 配布の依頼が届いた時点で流れている配布が無ければ、他のレーンを待たずに直ちに配布を組む。配布の中身は、 @@ -38,18 +65,67 @@ 承認の答えの時点で `develop` が進んでいれば、加わった範囲を添えて同じ承認ゲート 2 を 1 回だけ出し直す。 流れている配布は 1 本までで、範囲に入らない依頼は今の配布が終わった直後の次の配布になる。 +## 一時レーン(`--parallel-helper`) + +持ち主のいない共有の作業は、オーガナイザーが `metrics.spawn` の 1 行で起こす一時レーン(`<計画名>-rel<連番>`、役割 `helper`)が行う。 +最初の入力は次のどちらかで、`development-workflow` は判定の手順へ進まずこの節の手順で進める。 + +```text +/goal /ndf:development-workflow --parallel-helper p1 <連番> <提示> -- 終わり: [ndf-parallel] helper-done p1 の行を出したとき +/goal /ndf:development-workflow --parallel-helper p1 pending <ループ名> -- 終わり: [ndf-parallel] helper-done p1 の行を出したとき +``` + +**配布(`<連番> <提示>`):** + +1. `python3 "$NDF_SCRIPTS/parallel.py" helper p1 <連番> <提示>` を打つ。`metrics.phase` が流すものを決める +2. `metrics.pipeline` を流す(待ちは背景で打ち、完了の通知を待つ) + - `phase: present`: 提示のプラン → 本番のプランの queue と待ち。提示のプランが承認ゲート 2 で止まったら(done の提示のプランの結果が承認ゲートで止まった値)、 + `metrics.gate2` の 1 行で承認ゲート 2 の依頼を送り、手順 4 へ進む。流れ切れば手順 3 へ進む + - `phase: confirmed`: 答えの記録 → 提示のプランを `approve` から → 本番のプランの queue と待ち。`approve` が承認ゲート(終了コード 10)で + 終われば本番のプランを流さず、問いとして送って手順 4 へ進む +3. `metrics.settle` の 1 行(`parallel.py settle p1 <連番> --done `)を打つ。結果は耐久ワークフローが範囲のレーンへ返す +4. `[ndf-parallel] helper-done p1` の行を出して終える。答えの後は、オーガナイザーが続きの一時レーンを起こす + +CI の失敗・衝突・版数の衝突は、配布のプランの直しの手順(judge・fix)で直す。直せない失敗は問いにする(区分つきの `AskUserQuestion` を +出すと、問いの hook がオーガナイザーへ送り、拒否の理由に保留の手順が返る)。保留したら手順 4 で終える。 + +**保留の周回(`pending <ループ名>`):** `python3 "$NDF_SCRIPTS/sprint-state.py" loop pending <ループ名>` を打ち、`metrics.pipeline` を流す。 +`metrics.status` が `none` になるまで繰り返し、手順 4 で終える。 + +一時レーンは並列の計画の `lanes` に載らず、配布の依頼を送らない。同時に 1 本までで、同時に動かせる数を 1 本だけ超えて通る。 + ## レーンの側 並列の計画の下のレーン(`NDF_RELAY_NAME` が動いている計画のレーンにある)では、スプリントの本番のステージが -配布の依頼のプラン(ステップ `parallel-request` の 1 つ)になる。依頼を送ったら承認ゲートとして終わり、レーンは答えを待たずに -次の周回へ進む。答えは `parallel.py await p1 --from <プランの状態ディレクトリ>/work/request.txt --timeout 0` で待たずに読む。自分の Pull Request が -すでに `main` にあれば、依頼を送らずに配布済みとして続きへ進む。レーンで `supervise.py queue` の `--max` を省くと、 +配布の依頼のプラン(ステップ `parallel-request` の 1 つ。フェーズ「配布の依頼」)になる。依頼を送ったらプランは承認ゲートとして終わる。 +自分の Pull Request がすでに `main` にあれば、依頼を送らずに配布済みとして続きへ進む。レーンで `supervise.py queue` の `--max` を省くと、 並列の計画のそのレーンのプランの本数で流す。 +| レーン | 依頼の後 | 答えが届いたら | +| --- | --- | --- | +| 開発ループ | `loop lap` が周回を「配布待ち」で締め、答えを待たずに次の周回へ進む([loop.md](loop.md)) | 周回の境の `loop select` が答えを読み、まとめを流して周回を締め直す | +| 1 スプリント | `parallel.py await p1 --from <プランの状態ディレクトリ>/work/request.txt` を背景で打って待つ | `released` ならまとめのステージを流す。`rejected` は今の承認ゲート 2 の差し戻しと同じに扱う | + +承認ゲート 1・承認ゲート 2・承認ゲートの外の問いは、どのレーンでもオーガナイザーへ送られる([asking.md](asking.md) の「並列の計画のレーン」)。 + ## 止める -`python3 "$NDF_SCRIPTS/parallel.py" stop p1` で計画の全レーンへ停止を打ち、耐久ワークフローを終える。止まらないレーンがあれば -名前を返して 1 で終わる。1 本だけを止めるのは `relay.py stop <名前>` である。 +`python3 "$NDF_SCRIPTS/parallel.py" stop p1` で計画の全レーンへ停止を打ち、耐久ワークフローを終える。配布の途中なら、配布が終わる +(`settle` が届く)のを待ってから終える。`--force` を付けると待たずに終え、一時レーンも止める(本番のプランの状態ディレクトリは +残り、`supervise.py queue ... --resume` で続けられる)。止まらないレーンがあれば名前を返して 1 で終わる。1 本だけを止めるのは +`relay.py stop <名前>` である。 + +## 異常のとき + +耐久ワークフローは次の場面で自分で問いを立て、`questions` で返す。 + +| 場面 | 問い | +| --- | --- | +| レーンが承認待ちでも止まってもいないのに、最後に動いた時刻から 60 分(`NDF_PARALLEL_STALL_MINUTES`)を越えた | 起こし直す / 止める / 待つ(次の区切りまで問わない) | +| 配布の途中で一時レーンが止まり、`settle` も承認ゲート 2 の依頼も届いていない | 続きから起こし直す / 止める | +| レーンの起動が 3 回続けて断られた | 起こし直す / 止める | + +全レーンが止まっても、答えていない依頼か問いが残る間は終えない。 ## 数え方 From b9f9a4801cff1f0965cc028d39e5e435e5df83aa Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 08:29:27 +0000 Subject: [PATCH 37/70] =?UTF-8?q?docs(release):=20=E6=AF=94=E3=81=B9?= =?UTF-8?q?=E3=81=9F=E5=85=88=E7=AB=AF=E3=82=92=20release/v<=E7=89=88>=20?= =?UTF-8?q?=E3=81=AE=E5=85=88=E7=AB=AF=E3=81=A8=E3=81=97=E3=81=A6=E7=A2=BA?= =?UTF-8?q?=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=A8=E7=94=A8=E8=AA=9E=E9=9B=86?= =?UTF-8?q?=E3=81=AB=E6=9B=B8=E3=81=8F=EF=BC=88#2436=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本番チャネルへの Pull Request の head を release/v<版> にした実装に合わせ、 承認したコミットの確定仕様・リリース記録の確定仕様・用語集の「比べた先端」と 本番のプランの probe のコメントを今の決まりに直す。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 2 +- docs/glossary/glossary.json | 2 +- .../ndf-production-release-record.md | 2 +- .../ndf-release-approved-commit.md | 21 ++++++++++++------- .../supervise_lib/release_templates.py | 2 +- 5 files changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/glossary.md b/docs/glossary.md index eda7811bc..60ed1f2ae 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -623,7 +623,7 @@ release が走らせるリリースの種別・リリースコマンド・公開 | 移行の手順 | — | 利用者が新しい版へ移るときに自分で行う操作(引数の置き換え・認証のし直し・導入し直しなど)。PR 本文の「移行の手順」の節が正 | — | — | `docs/specifications/ndf-release-other-plugin-levels-and-migration.md` | | 互換なしの印 | — | 要求の「影響」の表の「公開インタフェース」の行に書く、互換の経路を持たないことを示す語(互換なし / 互換の経路は持たない)。release-steps.py changed-plugins が上げ幅の候補を MAJOR にする材料 | — | — | `docs/specifications/ndf-release-other-plugin-levels-and-migration.md` | | 承認したコミット | — | ゲート 2 の承認資料が対象にした、ベースブランチの先端のコミットの SHA(40 桁)。本番チャネルへ入れてよい中身の上限を示す | — | — | `docs/specifications/ndf-release-approved-commit.md` | -| 比べた先端 | — | 本番の配布が承認したコミットと比べた時点の、ベースブランチの先端のコミットの SHA。本番チャネルへマージしてよいのはこのコミットだけである | — | — | `docs/specifications/ndf-release-approved-commit.md` | +| 比べた先端 | — | 本番の配布が承認したコミットと比べた時点の、本番チャネルへ出すブランチの先端のコミットの SHA(本番の配布では `release/v<版>`、昇格の経路ではベースブランチ)。本番チャネルへマージしてよいのはこのコミットだけである | — | — | `docs/specifications/ndf-release-approved-commit.md` | | 確かめたコミット | — | 導入の確認を走らせた、ベースブランチの先頭のコミットの SHA(40 桁)。導入の確認の承認資料に書かれ、pace: fast / auto の昇格でマージしてよい先端はこのコミットに限る | — | — | `docs/specifications/ndf-release-approved-commit.md` | | 承認の外のコミット | — | 承認したコミットから比べた先端までに入ったコミットのうち、この版の配布の PR のものでないもの。配布物(`plugins/`・`.claude-plugin/`)に触れるものがあれば本番の配布は承認ゲートで止まる | — | — | `docs/specifications/ndf-release-approved-commit.md` | | 承認の記録 | — | 承認資料の欄の 1 つ。ゲート 2 を承認したときの承認したコミットの SHA と、承認した者・時刻を持つ。承認資料を書き直すと消え、承認し直すまで本番の配布は通らない | — | — | `docs/specifications/ndf-release-approved-commit.md` | diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 608091ad3..1481e18a6 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -3533,7 +3533,7 @@ { "term": "比べた先端", "context": "ndf-release", - "meaning": "本番の配布が承認したコミットと比べた時点の、ベースブランチの先端のコミットの SHA。本番チャネルへマージしてよいのはこのコミットだけである", + "meaning": "本番の配布が承認したコミットと比べた時点の、本番チャネルへ出すブランチの先端のコミットの SHA(本番の配布では `release/v<版>`、昇格の経路ではベースブランチ)。本番チャネルへマージしてよいのはこのコミットだけである", "source": "docs/specifications/ndf-release-approved-commit.md" }, { diff --git a/docs/specifications/ndf-production-release-record.md b/docs/specifications/ndf-production-release-record.md index 1fc3d93be..71e2de101 100644 --- a/docs/specifications/ndf-production-release-record.md +++ b/docs/specifications/ndf-production-release-record.md @@ -137,7 +137,7 @@ python3 release-steps.py record --version <版> --prs ... [--plugin < | 項目 | 内容 | | --- | --- | -| 前提(この順に確かめる) | タグ `--v<版>` が origin にある(`git ls-remote --exit-code --tags`)。同じタグの GitHub Release がある(`gh release view <タグ>` が 0)。`release/v<版>` → ベースブランチの PR が MERGED で見つかる | +| 前提(この順に確かめる) | タグ `--v<版>` が origin にある(`git ls-remote --exit-code --tags`)。同じタグの GitHub Release がある(`gh release view <タグ>` が 0)。`release/v<版>` → ベースブランチの PR が MERGED で見つかる。無ければ、版数の衝突を解いて取り込んだ `release/v<版>-sync` → ベースブランチの PR を MERGED で探し、その PR へ書く | | 書くもの | その PR へコメント 1 件(`gh pr comment <番号> --body-file <一時ファイル>`)。`--repo` を渡さず、作業場所のリポジトリへ書く | | 書かないとき | PR の本文とコメントを投稿の順に並べた最後の記録が、`stage` が `本番` で始まり `version` と `sprint_prs` が同じとき | | 結果 JSON | `lib/step_result.py` の形。`items` は `{kind: "comment", name: "#<番号>", result: "posted" / "exists"}`。`metrics` は `release_pr`・`release_pr_url`・`version`・`prev_version`・`sprint_prs` | diff --git a/docs/specifications/ndf-release-approved-commit.md b/docs/specifications/ndf-release-approved-commit.md index 60134f42e..291774c2f 100644 --- a/docs/specifications/ndf-release-approved-commit.md +++ b/docs/specifications/ndf-release-approved-commit.md @@ -18,9 +18,9 @@ | --- | --- | | 1 | 開発版のリリースプランの `approval-facts` が、打った時点の `origin/<ベースブランチ>` の 40 桁を承認資料の「承認したコミット」の行と `metrics.approved_sha` に書く。`next` は `release ... --channel prod --approved-sha <40 桁>` | | 2 | 利用者が承認資料を見て承認する。conductor が `approve --approval <承認資料> --approved-sha <1 の metrics.approved_sha> --by user` で「承認の記録」を書く(MVV 判定で通したときは `mvv-gate.py` が `by: mvv` で書く)。`--state` を渡した `pace: normal` では、conductor は `sprint-state.py gate <状態> "関門 2" --by user --outcome approved` だけを打ち、本番のプランの先頭の `approve --facts-from` が書く | -| 3 | 別の PR がベースブランチへマージされる | +| 3 | 本番のリリースプランが `release/v<版>` を切る前に、別の PR がベースブランチへマージされる | | 4 | 本番のリリースプランの `release --channel prod --approval <承認資料>` が、承認の記録と承認したコミットが等しいことを確かめ、配布の PR(`release/v<版>` → ベースブランチ)をマージする | -| 5 | 承認したコミットから `origin/<ベースブランチ>` の先端までに、配布の PR の外のコミット(3 の PR のもの)があるので、本番チャネルの PR を作らずに `gate`・終了コード 10 で止まる。`items[]` に外のコミットの SHA・件名・PR 番号が並ぶ | +| 5 | `release/v<版>` はベースブランチの先頭から切ったため、承認したコミットから `origin/release/v<版>` の先端までに配布の PR の外のコミット(3 の PR のもの)があり、本番チャネルの PR を作らずに `gate`・終了コード 10 で止まる。`items[]` に外のコミットの SHA・件名・PR 番号が並ぶ | | 6 | 人が外のコミットを見て、`approval-facts` で承認資料を作り直し、承認を取り直して打ち直す | **打ち方・`status` の読み方・止まったときの次の手は Skill が正である。** ここに書き写さない。 @@ -44,7 +44,7 @@ | 語 | 指すもの | | --- | --- | | 配布の PR | この版の `release/v<版>` → ベースブランチの Pull Request(開発版の接尾辞を外す bump・changelog・他のプラグインの bump を含む) | -| 本番チャネルの PR | ベースブランチ → 本番チャネルの Pull Request | +| 本番チャネルの PR | `release/v<版>` → 本番チャネルの Pull Request。`release/v<版>` は先にベースブランチへ取り込み、先頭をベースブランチの祖先にしてから出す | | 承認資料 | `issues/approval-<プラグイン>-v<版>.md`。`approval-facts` が書く | | 導入の確認の承認資料 | 昇格のプランの `verify` ステップ(`release-steps.py verify-facts`)が書く `approval-verify.md`。確かめたコミットの行を 1 行持つ | | 昇格の Pull Request | ベースブランチ → 本番チャネルの Pull Request を `merged-steps.py promote` が作ってマージするもの。head はベースブランチそのもの | @@ -53,6 +53,11 @@ ベースブランチ・本番チャネル・プラグイン名は宣言(`.ndf/worktree.json` の `base_branch`・`production_branch` と `.ndf/supervise.json` の `release.plugin`)と引数から読み、 `develop` / `main` を埋め込まない。 +**比べた先端は `origin/release/v<版>` の先端で、ベースブランチの先端ではない。** 本番のリリースプランは、「起点のコミット」が +あればそのコミット(並列の計画のまとめた配布では承認したコミット)から、無ければ `origin/<ベースブランチ>` から `release/v<版>` を切り、 +版上げのコミットを積む。`release/v<版>` を切った後にベースブランチへ入った変更は比べる相手に入らず、本番チャネルにも入らない。 +その変更は次の配布で出る。 + ## 対象範囲 | 扱う | 扱わない | @@ -72,8 +77,8 @@ ミッションの外の PR #806 がベースブランチへマージされた。仕上げのフェーズが配布物の差分(119 → 124 ファイル)の 食い違いに気づいて止まり、再承認を経て配布したが、気づいたのは差分の数を数え直したからである(PR #810 の振り返り)。 -**承認したコミットとマージの直前のベースブランチの先端は、正しく進めても一致しない。** 本番のリリースプランは承認の後に -配布の PR をベースブランチへマージしてから本番チャネルの PR をマージするためである。比べるのは「承認したコミットから +**承認したコミットと比べた先端は、正しく進めても一致しない。** 本番のリリースプランは承認の後に +`release/v<版>` へ版上げのコミットを積み、配布の PR をベースブランチへマージしてから本番チャネルの PR をマージするためである。比べるのは「承認したコミットから 先端までに、配布の PR の外のコミットや中身が無いか」である。 **承認資料にコミットを書いて本番の範囲を縛る形は、手で反映する経路(`release-steps.py deploy-facts`)で既に使っていた。** @@ -137,7 +142,7 @@ sequenceDiagram R->>A: from_material(無い・形が違う → 3。承認の記録が無い・違う → 10) R->>G: 配布の PR をマージ(マージ済みなら続きから) R->>G: 配布の PR の先端・コミット・mergeCommit を読む - R->>A: compare_approved(承認したコミット, origin/, 配布の PR) + R->>A: compare_approved(承認したコミット, origin/release/v<版>, 配布の PR) alt 一致しない・判定できない A-->>R: Verdict(ok=false) R-->>P: gate(10)と承認の外のコミット @@ -163,8 +168,8 @@ sequenceDiagram | --- | --- | --- | | 承認資料を書いた(承認したコミットを含む) | `approval-facts` | 承認する人・MVV 判定・本番のリリースプラン | | ゲート 2 を承認した(承認の記録を書いた) | 人(conductor が `approve --by user`。`--state` の `normal` では本番のプランの先頭の `approve --facts-from` が利用者の承認の記録から書く)か MVV 判定(`mvv-gate.py check --gate release`。`--advise` では書かない) | conductor・本番の配布 | -| ほかの変更がベースブランチへマージされた | 利用者か別のプラン | 本番の配布(比較で見つける) | -| 承認したコミットからベースブランチの先端までを比べた | `lib/approved_commit.py` の `compare_approved` | `release --channel prod`(一致なら本番チャネルへ、違えば承認ゲート) | +| ほかの変更がベースブランチへマージされた | 利用者か別のプラン | 本番の配布(`release/v<版>` を切る前なら比較で見つける。切った後なら比べる相手に入らない) | +| 承認したコミットから `release/v<版>` の先端までを比べた | `lib/approved_commit.py` の `compare_approved` | `release --channel prod`(一致なら本番チャネルへ、違えば承認ゲート) | | 比べた先端を本番チャネルへマージした | `merged-steps.py merge-when-green --expect-head` | `release --channel prod`(タグと GitHub Release へ) | ### 比較の順序(`compare_approved`) diff --git a/plugins/ndf/scripts/supervise_lib/release_templates.py b/plugins/ndf/scripts/supervise_lib/release_templates.py index eee63db03..ef9500d86 100644 --- a/plugins/ndf/scripts/supervise_lib/release_templates.py +++ b/plugins/ndf/scripts/supervise_lib/release_templates.py @@ -236,7 +236,7 @@ def _release_step(a, v: str, dev: bool, repo: str | None, approval: str) -> dict "on_exit": dict(INFRA_WAIT_STOP), "on_fail": "judge", "next": "verify" if dev else "record", - # 配布の PR(release/v<版> → 起点)と、本番では続く 起点 → 本番 の PR のチェックを調べる + # 配布の PR(release/v<版> → 起点)と、本番では続く release/v<版> → 本番 の PR のチェックを調べる(#2436 の R1) "probe": {"cmd": f"{MERGED_PY} probe --head {{branch}} --head {{base}} --act"}, } if not dev: From adc539cf8394af66a937dc07daf3af85790f4209 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 08:30:39 +0000 Subject: [PATCH 38/70] =?UTF-8?q?fix(parallel):=20=E5=95=8F=E3=81=84?= =?UTF-8?q?=E3=81=AE=20hook=20=E3=81=8C=E8=AA=AD=E3=82=80=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=81=AE=E5=88=A4=E5=AE=9A=E3=82=92=E8=BB=BD=E3=81=84?= =?UTF-8?q?=E9=83=A8=E5=93=81=EF=BC=88parallel=5Ffiles=EF=BC=89=E3=81=B8?= =?UTF-8?q?=E5=88=86=E3=81=91=E3=80=81=E5=A5=91=E7=B4=84=E3=81=AB=E6=96=B0?= =?UTF-8?q?=E3=81=97=E3=81=84=E5=BC=95=E6=95=B0=E3=83=BB=E7=92=B0=E5=A2=83?= =?UTF-8?q?=E5=A4=89=E6=95=B0=E3=83=BB=E5=8F=97=E3=81=91=E6=B8=A1=E3=81=97?= =?UTF-8?q?=E3=81=AE=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=82=92=E8=B6=B3?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 問いの hook が parallel_lane(pydantic と耐久の記録)を辿らないようにし、hook の 10 秒の上限と hook.py の契約を保つ。 sprint-state.py は答えの読み取りと設計の承認の依頼で並列の計画の部品を辿るため、その環境変数・設定・ファイルを契約へ足す。 Co-Authored-By: Claude Opus 5.5 --- .../plugins__ndf__scripts__parallel.py.json | 44 +++++++++++++++++- ...lugins__ndf__scripts__sprint-state.py.json | 22 ++++++++- .../env/NDF_PARALLEL_STALL_MINUTES.json | 5 +++ plugins/ndf/contracts/index.md | 35 ++++++++------- plugins/ndf/scripts/hook_lib/ask_guard.py | 4 +- plugins/ndf/scripts/lib/parallel_files.py | 45 +++++++++++++++++++ plugins/ndf/scripts/lib/parallel_lane.py | 34 +------------- plugins/ndf/scripts/lib/parallel_plan.py | 3 +- 8 files changed, 136 insertions(+), 56 deletions(-) create mode 100644 plugins/ndf/contracts/env/NDF_PARALLEL_STALL_MINUTES.json create mode 100644 plugins/ndf/scripts/lib/parallel_files.py diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json index fbbef4a3b..3fa013b33 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json @@ -6,6 +6,19 @@ "options": [], "positionals": [], "subcommands": { + "answer": { + "options": [ + "--approved", + "--choice", + "--note", + "--rejected", + "--revise" + ], + "positionals": [ + "name", + "id" + ] + }, "await": { "options": [ "--from", @@ -30,6 +43,14 @@ "n" ] }, + "helper": { + "options": [], + "positionals": [ + "name", + "n", + "k" + ] + }, "init": { "options": [], "positionals": [ @@ -57,6 +78,24 @@ "name" ] }, + "request-ask": { + "options": [ + "--lane" + ], + "positionals": [ + "name" + ] + }, + "request-gate2": { + "options": [ + "--report" + ], + "positionals": [ + "name", + "n", + "k" + ] + }, "run": { "options": [], "positionals": [ @@ -81,7 +120,9 @@ ] }, "stop": { - "options": [], + "options": [ + "--force" + ], "positionals": [ "name" ] @@ -114,6 +155,7 @@ "NDF_PARALLEL_MAX_PLANS", "NDF_PARALLEL_POLL", "NDF_PARALLEL_RELAY", + "NDF_PARALLEL_STALL_MINUTES", "NDF_PRESENTATION_DIR", "NDF_RELAY_DIR", "NDF_RELAY_MAX_NAMED", diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-state.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-state.py.json index e195e9820..8bd45e6cb 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-state.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-state.py.json @@ -9,6 +9,8 @@ "CLAUDE_CODE_SESSION_ID", "CLAUDE_CONFIG_DIR", "CODEX_HOME", + "GH_TOKEN", + "GITHUB_TOKEN", "HOME", "MONITOR_NO_EARLY_ERROR", "NDF_ACCOUNTS_DIR", @@ -23,8 +25,11 @@ "NDF_ESTIMATE_DIR", "NDF_LOOP_EXCLUDE", "NDF_MVV_STATE_DIR", + "NDF_PARALLEL_MAX_PLANS", "NDF_PRESENTATION_DIR", "NDF_RELAY_DIR", + "NDF_RELAY_MAX_NAMED", + "NDF_RELAY_NAME", "NDF_SHARED_CONFIG_DIR", "NDF_SUPERVISE_CLAUDE", "NDF_SUPERVISE_CLAUDE_FALLBACK", @@ -39,7 +44,9 @@ { "file": "estimate.json", "keys": [ - "records_dir" + "min_samples", + "records_dir", + "window" ] }, { @@ -72,11 +79,22 @@ "stdin": "none", "stdout": "json", "writes": [ + "answer-json", "applied-json", + "approved-txt", + "breakdown-md", "cost-estimates-json", "decisions-jsonl", "laps-md", - "loop-json" + "loop-json", + "plan-json", + "plan-md", + "present-json", + "presented-json", + "prs-txt", + "request-id-txt", + "request-txt", + "requests-json" ], "reads": [], "exit": { diff --git a/plugins/ndf/contracts/env/NDF_PARALLEL_STALL_MINUTES.json b/plugins/ndf/contracts/env/NDF_PARALLEL_STALL_MINUTES.json new file mode 100644 index 000000000..db5b03b76 --- /dev/null +++ b/plugins/ndf/contracts/env/NDF_PARALLEL_STALL_MINUTES.json @@ -0,0 +1,5 @@ +{ + "name": "NDF_PARALLEL_STALL_MINUTES", + "set_by": "user", + "summary": "並列の計画の run が動かないレーンを問うまでの分(既定 60。plugins/ndf/scripts/lib/parallel_run.py)" +} diff --git a/plugins/ndf/contracts/index.md b/plugins/ndf/contracts/index.md index 97e04b959..a4046a4b4 100644 --- a/plugins/ndf/contracts/index.md +++ b/plugins/ndf/contracts/index.md @@ -11,7 +11,7 @@ | `backlog.json` | worktree | team | `plugins/ndf/skills/backlog-refinement/scripts/upkeep_rank_cmd.py:read_backlog_decl` | — | | `code-metrics.json` | worktree | team | `plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/codemetrics.py:load_config` | — | | `context.json` | worktree | team | `plugins/ndf/scripts/lib/context_policy.py:read_setting` | — | -| `estimate.json` | worktree | team | `plugins/ndf/scripts/lib/estimate_records.py:load_estimate_decl` | `plugins/ndf/scripts/estimate.py(records_dir)`
`plugins/ndf/scripts/parallel.py(min_samples, records_dir, window)`
`plugins/ndf/scripts/release-steps.py(records_dir)`
`plugins/ndf/scripts/sprint-state.py(records_dir)`
`plugins/ndf/scripts/supervise.py(min_samples, records_dir, window)`
`plugins/ndf/scripts/test-run.py(records_dir)`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py(records_dir)`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py(records_dir)`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py(records_dir)`
`plugins/ndf/skills/cross-review/scripts/drive.py(records_dir)` | +| `estimate.json` | worktree | team | `plugins/ndf/scripts/lib/estimate_records.py:load_estimate_decl` | `plugins/ndf/scripts/estimate.py(records_dir)`
`plugins/ndf/scripts/parallel.py(min_samples, records_dir, window)`
`plugins/ndf/scripts/release-steps.py(records_dir)`
`plugins/ndf/scripts/sprint-state.py(min_samples, records_dir, window)`
`plugins/ndf/scripts/supervise.py(min_samples, records_dir, window)`
`plugins/ndf/scripts/test-run.py(records_dir)`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py(records_dir)`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py(records_dir)`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py(records_dir)`
`plugins/ndf/skills/cross-review/scripts/drive.py(records_dir)` | | `glossary.json` | worktree | team | `plugins/ndf/scripts/glossary.py:load_declaration` | — | | `instructions.json` | worktree | team | `plugins/ndf/scripts/instructions_lib/declaration.py:load` | — | | `mvv.json` | worktree | team | `plugins/ndf/scripts/lib/project_mvv.py:decl_paths` | — | @@ -31,12 +31,12 @@ | 名前 | 型 | 種類 | 置き場 | 書くエントリポイント | 読むエントリポイント・Skill | | --- | --- | --- | --- | --- | --- | | `actuals-run-json` | `actuals-run.json` | result | state-dir | `plugins/ndf/scripts/supervise.py` | — | -| `answer-json` | `answer.json` | state | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | +| `answer-json` | `answer.json` | state | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | — | | `applied-json` | `*.applied.json` | state | state-dir | `plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | — | | `apply-json` | `apply.json` | result | tmp | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | -| `approved-txt` | `approved.txt` | result | state-dir | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | +| `approved-txt` | `approved.txt` | result | state-dir | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | — | | `before-json` | `before.json` | record | main | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | -| `breakdown-md` | `breakdown.md` | report | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | +| `breakdown-md` | `breakdown.md` | report | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | — | | `candidates-json` | `candidates.json` | result | tmp | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | | `cost-estimates-json` | `*.cost-estimates.json` | result | state-dir | `plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | — | | `counts-json` | `counts.json` | result | tmp | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | @@ -46,16 +46,16 @@ | `laps-md` | `laps.md` | report | main | `plugins/ndf/scripts/sprint-state.py` | — | | `loop-json` | `loop.json` | result | main | `plugins/ndf/scripts/sprint-state.py` | — | | `measures-json` | `measures.json` | result | tmp | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | -| `plan-json` | `plan.json` | record | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | -| `plan-md` | `plan.md` | report | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | -| `present-json` | `present.json` | record | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | -| `presented-json` | `presented.json` | result | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | -| `prs-txt` | `prs.txt` | result | state-dir | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | +| `plan-json` | `plan.json` | record | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | — | +| `plan-md` | `plan.md` | report | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | — | +| `present-json` | `present.json` | record | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | — | +| `presented-json` | `presented.json` | result | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | — | +| `prs-txt` | `prs.txt` | result | state-dir | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | — | | `rank-json` | `rank.json` | result | tmp | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | | `report-md` | `report.md` | report | state-dir | `plugins/ndf/scripts/supervise.py` | — | -| `request-id-txt` | `request-id.txt` | state | state-dir | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | -| `request-txt` | `request.txt` | result | state-dir | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | -| `requests-json` | `requests.json` | state | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | — | +| `request-id-txt` | `request-id.txt` | state | state-dir | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | — | +| `request-txt` | `request.txt` | result | state-dir | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | — | +| `requests-json` | `requests.json` | state | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | — | | `research-calls-jsonl` | `calls.jsonl` | record | main | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | | `research-item-json` | `*--*.item.json` | record | main | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | | `run-log` | `run.log` | report | main | `plugins/ndf/scripts/parallel.py` | — | @@ -84,8 +84,8 @@ | `CROSS_REVIEW_TMP_DIR` | runtime | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | | `DEBUG_SLACK_NOTIFY` | runtime | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/wait-notify.py` | | `DEVBASE_SHELLRC_DIR` | runtime | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | -| `GH_TOKEN` | runtime | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/refactor-scope.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py` | -| `GITHUB_TOKEN` | ci | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/refactor-scope.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py` | +| `GH_TOKEN` | runtime | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/refactor-scope.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py` | +| `GITHUB_TOKEN` | ci | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/refactor-scope.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py` | | `GIT_REPO` | runtime | `plugins/ndf/scripts/wait-notify.py` | | `HOME` | runtime | `plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/install-official-skills.sh`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/resolve.sh`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/statusline-switch.sh`
`plugins/ndf/scripts/statusline.sh`
`plugins/ndf/scripts/token-guard.sh`
`plugins/ndf/scripts/worktree-guard.sh`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | | `KIRO_SESSION_ID` | runtime | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/wait-notify.py` | @@ -112,17 +112,18 @@ | `NDF_LOOP_EXCLUDE` | runtime | `plugins/ndf/scripts/sprint-state.py` | | `NDF_MVV_CLAUDE` | user | `plugins/ndf/scripts/mvv-gate.py` | | `NDF_MVV_STATE_DIR` | user | `plugins/ndf/scripts/ask.py`
`plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py` | -| `NDF_PARALLEL_MAX_PLANS` | user | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | +| `NDF_PARALLEL_MAX_PLANS` | user | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | | `NDF_PARALLEL_POLL` | user | `plugins/ndf/scripts/parallel.py` | | `NDF_PARALLEL_RELAY` | user | `plugins/ndf/scripts/parallel.py` | +| `NDF_PARALLEL_STALL_MINUTES` | user | `plugins/ndf/scripts/parallel.py` | | `NDF_PRESENTATION_DIR` | user | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/ask.py`
`plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/design-match.py`
`plugins/ndf/scripts/doc-lint.py`
`plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/glossary.py`
`plugins/ndf/scripts/handoff.py`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/plan-to-spec-steps.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/project-decl.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/release-verification-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/stage-skills.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/google-workspace/scripts/gws-check.py`
`plugins/ndf/skills/google-workspace/scripts/slides-thumbnails.py`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py` | | `NDF_PROGRESS_LOCK_TIMEOUT` | user | `plugins/ndf/scripts/progress-record.sh` | | `NDF_RELAY` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | | `NDF_RELAY_CLAUDE` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | | `NDF_RELAY_DEPTH` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | | `NDF_RELAY_DIR` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py` | -| `NDF_RELAY_MAX_NAMED` | user | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/supervise.py` | -| `NDF_RELAY_NAME` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/supervise.py` | +| `NDF_RELAY_MAX_NAMED` | user | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | +| `NDF_RELAY_NAME` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | | `NDF_RELAY_REEXEC` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | | `NDF_RELAY_SPAWNED` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | | `NDF_RELAY_TMUX` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | diff --git a/plugins/ndf/scripts/hook_lib/ask_guard.py b/plugins/ndf/scripts/hook_lib/ask_guard.py index 1a054d034..339888bc6 100644 --- a/plugins/ndf/scripts/hook_lib/ask_guard.py +++ b/plugins/ndf/scripts/hook_lib/ask_guard.py @@ -122,10 +122,10 @@ def forward_to_organizer(ev, row_id: str) -> dict | None: if not name: return None import decl - import parallel_lane + import parallel_files main = decl.main_dir(ev.cwd) or Path(ev.cwd) - plan = parallel_lane.lane_name_in_plans(main, name, os.environ.get("NDF_RELAY_DIR")) + plan = parallel_files.lane_name_in_plans(main, name, os.environ.get("NDF_RELAY_DIR")) if not plan: return None code, got = run_request_ask(main, plan, name, {"tool_input": ev.tool_input, "cwd": ev.cwd, "ledger_id": row_id}) diff --git a/plugins/ndf/scripts/lib/parallel_files.py b/plugins/ndf/scripts/lib/parallel_files.py new file mode 100644 index 000000000..1e0e52593 --- /dev/null +++ b/plugins/ndf/scripts/lib/parallel_files.py @@ -0,0 +1,45 @@ +"""並列の計画の置き場をファイルだけで読む小さな部品(#2436)。 + +問いの hook(10 秒の上限)がレーンか一時レーンかを見るために使う。pydantic と DBOS を import しない。 +計画の形の検査は `parallel_plan.py`、耐久の記録を読むレーンの判定は `parallel_lane.py` が持つ。 +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional + +PARALLEL_DIR = Path(".ndf") / "parallel" +ROLE_FILE = "role.json" # 統括役の作業ディレクトリの役割のファイル(relay_lib.hub.ROLE_FILE と同じ公開の形。統括役を import しない) + + +def plans_in(main: Path) -> list[str]: + """メインディレクトリにある並列の計画の名前(`plan.json` を持つもの)。""" + root = Path(main) / PARALLEL_DIR + return sorted(p.name for p in root.iterdir() if (p / "plan.json").is_file()) if root.is_dir() else [] + + +def helper_plan_of(relay_dir: Optional[str]) -> Optional[str]: + """作業ディレクトリ(`NDF_RELAY_DIR`)の役割のファイルが `helper` なら、その計画名。""" + if not relay_dir: + return None + try: + got = json.loads((Path(relay_dir) / ROLE_FILE).read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return got.get("plan") if isinstance(got, dict) and got.get("role") == "helper" and got.get("plan") else None + + +def lane_name_in_plans(main: Path, name: Optional[str], relay_dir: Optional[str]) -> Optional[str]: + """レーンか一時レーンかをファイルだけで見る(DBOS を読まない)。計画名か None。""" + if not name: + return None + for plan in plans_in(main): + try: + lanes = json.loads((Path(main) / PARALLEL_DIR / plan / "plan.json").read_text(encoding="utf-8")).get("lanes") or [] + except (OSError, ValueError, AttributeError): + continue + if any(isinstance(ln, dict) and ln.get("name") == name for ln in lanes): + return plan + return helper_plan_of(relay_dir) diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py index 5a0256e0c..ccfaf2a9b 100644 --- a/plugins/ndf/scripts/lib/parallel_lane.py +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -17,6 +17,7 @@ from typing import Optional import parallel_plan as pp +from parallel_files import helper_plan_of, lane_name_in_plans, plans_in # noqa: F401 ファイルだけで読む判定(問いの hook も使う) KIND = "parallel" RUNNING = ("PENDING", "ENQUEUED") @@ -47,11 +48,6 @@ def run_status(main: Path, name: str) -> Optional[str]: return durable.status_from(KIND, record_identity(main, name), workflow_id(name)) -def plans_in(main: Path) -> list[str]: - root = Path(main) / pp.PARALLEL_DIR - return sorted(p.name for p in root.iterdir() if (p / "plan.json").is_file()) if root.is_dir() else [] - - def lane_of(main: Path, lane: Optional[str] = None, status=run_status) -> Optional[tuple[str, pp.Lane]]: """`NDF_RELAY_NAME` の名前が、耐久ワークフローが終わっていない計画のレーンにあれば (計画名, レーン)。無ければ None(I12)。""" lane = lane if lane is not None else os.environ.get("NDF_RELAY_NAME") @@ -208,34 +204,6 @@ def answers_of(main: Path, name: str, ids: list[str]) -> dict[str, dict]: # ---- 問いの依頼と答え(#2436 の 6c・6d・6f。設計 issue-2436-design-approvals.md) ---- -ROLE_FILE = "role.json" # relay_lib.hub.ROLE_FILE(統括役の作業ディレクトリの役割のファイル。統括役を import しない) - - -def helper_plan_of(relay_dir: Optional[str]) -> Optional[str]: - """作業ディレクトリ(`NDF_RELAY_DIR`)の役割のファイルが `helper` なら、その計画名。""" - if not relay_dir: - return None - try: - got = json.loads((Path(relay_dir) / ROLE_FILE).read_text(encoding="utf-8")) - except (OSError, ValueError): - return None - return got.get("plan") if isinstance(got, dict) and got.get("role") == "helper" and got.get("plan") else None - - -def lane_name_in_plans(main: Path, name: Optional[str], relay_dir: Optional[str]) -> Optional[str]: - """レーンか一時レーンかをファイルだけで見る(問いの hook の 10 秒の上限の中で DBOS を読まない)。計画名か None。""" - if not name: - return None - for plan in plans_in(main): - try: - lanes = json.loads((pp.plan_dir(main, plan) / "plan.json").read_text(encoding="utf-8")).get("lanes") or [] - except (OSError, ValueError, AttributeError): - continue - if any(isinstance(ln, dict) and ln.get("name") == name for ln in lanes): - return plan - return helper_plan_of(relay_dir) - - def sender_of(main: Path, name: Optional[str], relay_dir: Optional[str], status=run_status) -> Optional[tuple[str, str]]: """問いを送ってよい送り手か: 動いている計画のレーン(`lane_of`)か、役割のファイルが `helper` で、その計画の 耐久ワークフローが終わっていない一時レーン。(計画名, 送り手の名前) か None。""" diff --git a/plugins/ndf/scripts/lib/parallel_plan.py b/plugins/ndf/scripts/lib/parallel_plan.py index 7f4023037..145200cd1 100644 --- a/plugins/ndf/scripts/lib/parallel_plan.py +++ b/plugins/ndf/scripts/lib/parallel_plan.py @@ -19,13 +19,14 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, ValidationInfo, field_validator, model_validator from pydantic_core import PydanticCustomError +from parallel_files import PARALLEL_DIR # noqa: F401 置き場の正本(問いの hook も読む) + VERSION = 1 PLAN_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,23}$") # 計画名は 24 字まで(一時レーンの名前を 32 字に収める。I1) LANE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$") # 統括役の名前の形(relay_lib/common.py の NAME_RE と同じ) HELPER_RE = re.compile(r"-rel\d+$") # 一時レーンの名前の形(`<計画名>-rel<連番>`) DEFAULT_MAX_PLANS = 6 # 同時に流すプランの合計の既定(2026-09-25 の実測。8 コア・30 GB) DEFAULT_MAX_NAMED = 3 # relay_lib/hub.py の max_named の既定 -PARALLEL_DIR = Path(".ndf") / "parallel" MAX_PLANS_ENV = "NDF_PARALLEL_MAX_PLANS" From 8c04c457842fd86b2a11e320478293402f79698f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 08:31:53 +0000 Subject: [PATCH 39/70] =?UTF-8?q?style(parallel):=20=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=81=AE=20lambda=20=E3=81=AE=E4=BB=A3=E5=85=A5?= =?UTF-8?q?=E3=82=92=20def=20=E3=81=AB=E3=81=97=EF=BC=88E731=EF=BC=89?= =?UTF-8?q?=E3=80=81=E6=95=B4=E5=BD=A2=E3=81=AE=E7=B5=90=E6=9E=9C=E3=82=92?= =?UTF-8?q?=E5=8F=96=E3=82=8A=E8=BE=BC=E3=82=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/hook_lib/ask_guard.py | 2 +- plugins/ndf/scripts/lib/parallel_lane.py | 9 ++++++++- plugins/ndf/scripts/lib/parallel_run.py | 8 +++++++- plugins/ndf/scripts/lib/sprint_loop.py | 4 +--- plugins/ndf/scripts/lib/sprint_loop_select.py | 4 +++- plugins/ndf/scripts/tests/test_pace.py | 8 +++++++- .../ndf/scripts/tests/test_parallel_run.py | 20 +++++++++++++++---- plugins/ndf/scripts/tests/test_sprint_loop.py | 4 +++- 8 files changed, 46 insertions(+), 13 deletions(-) diff --git a/plugins/ndf/scripts/hook_lib/ask_guard.py b/plugins/ndf/scripts/hook_lib/ask_guard.py index 339888bc6..82ce84aa6 100644 --- a/plugins/ndf/scripts/hook_lib/ask_guard.py +++ b/plugins/ndf/scripts/hook_lib/ask_guard.py @@ -105,7 +105,7 @@ def run_request_ask(main: Path, plan: str, lane: str, payload: dict) -> tuple[in def hold_text(rid: str, plan: str) -> str: """オーガナイザーへ送った問いの理由と保留の手順(正本: asking.md の「並列の計画のレーン」)。""" - loop = f"python3 \"{Path(__file__).resolve().parent.parent / 'sprint-state.py'}\" loop hold-question <ループ> --ref {rid} --plan <止まったプラン> --step <続きのステップ> --parallel {plan}" + loop = f'python3 "{Path(__file__).resolve().parent.parent / "sprint-state.py"}" loop hold-question <ループ> --ref {rid} --plan <止まったプラン> --step <続きのステップ> --parallel {plan}' return "\n".join( [ f"ndf: 並列の計画 {plan} のレーンの問いをオーガナイザーへ送った(依頼 {rid})。この作業を保留し、別の作業へ進め。答えは周回の境で届く。", diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py index ccfaf2a9b..18104ca28 100644 --- a/plugins/ndf/scripts/lib/parallel_lane.py +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -204,6 +204,7 @@ def answers_of(main: Path, name: str, ids: list[str]) -> dict[str, dict]: # ---- 問いの依頼と答え(#2436 の 6c・6d・6f。設計 issue-2436-design-approvals.md) ---- + def sender_of(main: Path, name: Optional[str], relay_dir: Optional[str], status=run_status) -> Optional[tuple[str, str]]: """問いを送ってよい送り手か: 動いている計画のレーン(`lane_of`)か、役割のファイルが `helper` で、その計画の 耐久ワークフローが終わっていない一時レーン。(計画名, 送り手の名前) か None。""" @@ -278,7 +279,13 @@ def request_gate2(main: Path, name: str, n: int, k: int, report: str) -> str: def send_answer( - main: Path, name: str, rid: str, result: str, note: Optional[str] = None, choice: Optional[list[str]] = None, stamp: Optional[str] = None + main: Path, + name: str, + rid: str, + result: str, + note: Optional[str] = None, + choice: Optional[list[str]] = None, + stamp: Optional[str] = None, ) -> None: """オーガナイザーの答えを送る。耐久ワークフローが `answer-<依頼>` を 1 回だけ立て、問いの一覧から外す。""" msg = {"kind": "answer", "id": rid, "result": result, "note": note, "choice": list(choice or []), "stamp": stamp, "at": _now()} diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 964c6a025..f98be2ff9 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -549,7 +549,13 @@ def _spawn_ready(run: _Run, main: str) -> None: if got.get("refused"): run.refusals[n] = run.refusals.get(n, 0) + 1 if run.refusals[n] == SPAWN_REFUSALS: # 3 回続けて断られたら理由を添えて問う(異常系 17) - run.ask("spawn", f"{run.name}-spawn-{n}", n, f"レーン {n} を {SPAWN_REFUSALS} 回起動できない({got['refused']})", ["起こし直す", "止める"]) + run.ask( + "spawn", + f"{run.name}-spawn-{n}", + n, + f"レーン {n} を {SPAWN_REFUSALS} 回起動できない({got['refused']})", + ["起こし直す", "止める"], + ) def _run_finished(run: _Run) -> bool: diff --git a/plugins/ndf/scripts/lib/sprint_loop.py b/plugins/ndf/scripts/lib/sprint_loop.py index 15fa0ee34..37fc609c5 100644 --- a/plugins/ndf/scripts/lib/sprint_loop.py +++ b/plugins/ndf/scripts/lib/sprint_loop.py @@ -360,9 +360,7 @@ def cmd_sprint(env: Env, name: str, mode: str, dev: str, prod: str | None, extra # ---------------------------------------------------------------- answer / approve-mvv / stop -def cmd_answer( - env: Env, name: str, number: int, approved: bool, pr: int | None, revise: bool = False, note: str | None = None -) -> dict: +def cmd_answer(env: Env, name: str, number: int, approved: bool, pr: int | None, revise: bool = False, note: str | None = None) -> dict: with open_record(env, name) as (rec, before): w = next((w for w in reversed(rec.get("waiting", [])) if w["number"] == number and not w.get("answer")), None) if w is None: diff --git a/plugins/ndf/scripts/lib/sprint_loop_select.py b/plugins/ndf/scripts/lib/sprint_loop_select.py index 47237d344..889a0baba 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_select.py +++ b/plugins/ndf/scripts/lib/sprint_loop_select.py @@ -244,7 +244,9 @@ def run_selection(env: Env, rec: dict, before: dict, n: int, lap: dict | None = if lap is not None: rec["laps"].remove(lap) # スプリントを組む前の周回は取り下げ、除外の一覧(excluded の lap)を次の選定に残す if has_held_work(rec): # 選べる課題が無く答えを待つ作業だけが残る: no_issue で止まらず、見直しも挟まない(異常系 6) - return stop_and_report(env, rec, before, waiting_only(rec, "no_selectable"), "手元の作業が待ちだけになった", {"selection": got["selection"]}) + return stop_and_report( + env, rec, before, waiting_only(rec, "no_selectable"), "手元の作業が待ちだけになった", {"selection": got["selection"]} + ) cond = "no_issue" if got["candidates"] == 0 else "outside_mvv" if cond == "no_issue" and rec.get("review_first") != len(rec["laps"]): return review_first(env, rec, before, got) diff --git a/plugins/ndf/scripts/tests/test_pace.py b/plugins/ndf/scripts/tests/test_pace.py index 6196b6a18..4e8794f5f 100644 --- a/plugins/ndf/scripts/tests/test_pace.py +++ b/plugins/ndf/scripts/tests/test_pace.py @@ -107,7 +107,13 @@ def test_the_loop_section_is_read_with_defaults(tmp_path): assert pace.read_loop(write(tmp_path, DECL)) == pace.LOOP_DEFAULTS assert pace.read_loop(write(tmp_path, {**DECL, "loop": None})) == pace.LOOP_DEFAULTS d = pace.read_loop(write(tmp_path, {**DECL, "loop": {"enabled": True, "budget": {"laps": 5}, "max_issues": 1}})) - assert d == {"enabled": True, "budget": {"laps": 5, "usd": 60, "hours": 24}, "same_failure": 2, "max_issues": 1, "release_pending_max": 2} + assert d == { + "enabled": True, + "budget": {"laps": 5, "usd": 60, "hours": 24}, + "same_failure": 2, + "max_issues": 1, + "release_pending_max": 2, + } assert pace.read_loop(write(tmp_path, {**DECL, "loop": {"enabled": "yes"}}))["enabled"] is False diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index bc3f9fcec..060945770 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -404,7 +404,9 @@ def test_helper_task_follows_the_last_launch(env, tmp_path): d = prl.release_dir(main, "p1", 1, 1) assert task["phase"] == "present" and f"queue {d / 'present.json'}" in task["pipeline"] assert "request-gate2 p1 1 1" in task["gate2"] and "settle p1 1 --done" in task["settle"] - (d / "present.json").write_text(json.dumps({"steps": [{"id": "present", "cwd": "/r", "cmd": "parallel.py present p1 1 1 --approval issues/a.md && cat x"}]})) + (d / "present.json").write_text( + json.dumps({"steps": [{"id": "present", "cwd": "/r", "cmd": "parallel.py present p1 1 1 --approval issues/a.md && cat x"}]}) + ) assert prun.helper_task(main, "p1", 1, 1)["gate2"].endswith("--report /r/issues/a.md") (d / "presented.json").write_text(json.dumps({"sha": "sha1", "prs": [10]})) pl.request_gate2(main, "p1", 1, 1, str(d / "report.md")) @@ -450,8 +452,12 @@ def test_questions_of_the_same_cause_are_asked_once_and_answered_to_each(env, tm main = env["main"] env["states"].write_text(json.dumps({"la": "running", "lb": "running"})) start(main) - a = pl.request_ask(main, "p1", "la", {**ASK, "questions": [{"question": "gh の認証が切れた(/work/la の #12)", "options": []}]}, str(tmp_path), "x1") - b = pl.request_ask(main, "p1", "lb", {**ASK, "questions": [{"question": "gh の認証が切れた(/work/lb の #34)", "options": []}]}, str(tmp_path), "x2") + a = pl.request_ask( + main, "p1", "la", {**ASK, "questions": [{"question": "gh の認証が切れた(/work/la の #12)", "options": []}]}, str(tmp_path), "x1" + ) + b = pl.request_ask( + main, "p1", "lb", {**ASK, "questions": [{"question": "gh の認証が切れた(/work/lb の #34)", "options": []}]}, str(tmp_path), "x2" + ) item = until(lambda: [i for i in questions(main)["items"] if b in (i.get("also") or [])])[0] assert item["id"] == a and len(questions(main)["items"]) == 1 and "/work/lb の #34" in item["merged"][0]["question"] pl.send_answer(main, "p1", a, "answered", choice=["済んだ"]) @@ -522,7 +528,13 @@ def test_irreversible_questions_with_different_numbers_are_not_merged(env, tmp_p main = env["main"] env["states"].write_text(json.dumps({"la": "running", "lb": "running"})) start(main) - q = lambda n: {"questions": [{"question": f"PR #{n} を消してよいか", "options": [{"label": "はい"}]}], "metadata": {"source": "ndf-ask:irreversible"}} # noqa: E731 + + def q(n): + return { + "questions": [{"question": f"PR #{n} を消してよいか", "options": [{"label": "はい"}]}], + "metadata": {"source": "ndf-ask:irreversible"}, + } + pl.request_ask(main, "p1", "la", q(12), str(tmp_path), "y1") pl.request_ask(main, "p1", "lb", q(34), str(tmp_path), "y2") until(lambda: len(questions(main)["items"]) == 2) diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index 3990eaaa7..ab1aadac1 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -1473,7 +1473,9 @@ def test_gate_1_of_a_lane_sends_the_request_to_the_organizer(env, monkeypatch): lap = built_lap(env) report = design_report(env, "design-11", "https://github.com/o/r/pull/501") sent = [] - monkeypatch.setattr(sl_gate, "lane_gate1", lambda e, w, report, state: sent.append(w["number"]) or {"request": "lroot-g501-x", "plan": "p1"}) + monkeypatch.setattr( + sl_gate, "lane_gate1", lambda e, w, report, state: sent.append(w["number"]) or {"request": "lroot-g501-x", "plan": "p1"} + ) item = {"plan": str(Path(lap["out"]) / "design-11.json"), "result": "関門", "report": report} out = sl_gate.cmd_gate(env, "l1", write_done(env, [item])) w = rec(env)["waiting"][0] From 912b3e22525236fe8a9fbe2526e9d617d7707dc1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 08:34:19 +0000 Subject: [PATCH 40/70] =?UTF-8?q?fix(parallel):=20=E6=A7=8B=E9=80=A0?= =?UTF-8?q?=E3=81=AE=E6=A4=9C=E6=9F=BB=E3=82=92=E9=80=9A=E3=81=99=EF=BC=88?= =?UTF-8?q?=E5=90=8C=E5=90=8D=E3=81=AE=E8=A3=9C=E5=8A=A9=E9=96=A2=E6=95=B0?= =?UTF-8?q?=E3=82=92=E6=94=B9=E5=90=8D=E3=83=BBparallel.py=20=E3=82=92=205?= =?UTF-8?q?00=20=E8=A1=8C=E4=BB=A5=E4=B8=8B=E3=81=AB=E3=83=BB=E5=A2=97?= =?UTF-8?q?=E3=81=88=E3=81=9F=204=20=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB?= =?UTF-8?q?=E3=82=92=E8=A1=8C=E6=95=B0=E3=81=AE=E4=BE=8B=E5=A4=96=E3=81=B8?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_lane.py | 14 +++++++------- plugins/ndf/scripts/parallel.py | 3 --- ..._ndf__scripts__lib__parallel_run.py--lines.json | 1 + ...__ndf__scripts__lib__sprint_loop.py--lines.json | 1 + ...__scripts__lib__sprint_loop_gate.py--lines.json | 1 + ...scripts__lib__sprint_loop_record.py--lines.json | 1 + 6 files changed, 11 insertions(+), 10 deletions(-) create mode 100644 scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json create mode 100644 scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop.py--lines.json create mode 100644 scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json create mode 100644 scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_record.py--lines.json diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py index 18104ca28..39d2c90a5 100644 --- a/plugins/ndf/scripts/lib/parallel_lane.py +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -217,7 +217,7 @@ def sender_of(main: Path, name: Optional[str], relay_dir: Optional[str], status= return None -def _now() -> str: +def _utc_stamp() -> str: return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) @@ -228,7 +228,7 @@ def _send_inbox(main: Path, name: str, msg: dict, key: str) -> None: durable.send_to(KIND, record_identity(main, name), wid, msg, "inbox", f"{wid}:inbox:{key}") -def _head(cwd: str) -> str: +def _head_sha(cwd: str) -> str: got = subprocess.run(["git", "-C", cwd, "rev-parse", "HEAD"], capture_output=True, text=True) return got.stdout.strip() if got.returncode == 0 else "" @@ -248,8 +248,8 @@ def request_ask(main: Path, name: str, lane: str, tool_input: dict, cwd: str, le "questions": qs, "cwd": cwd, "ledger_id": ledger_id, - "stamp": _head(cwd), - "at": _now(), + "stamp": _head_sha(cwd), + "at": _utc_stamp(), } _send_inbox(main, name, msg, rid) return rid @@ -259,7 +259,7 @@ def request_gate1(main: Path, name: str, lane: str, issue: int, pr: int, report: """設計の承認の依頼(`kind: gate1`)。id は `<レーン>-g<設計 PR>-<先頭の SHA の 12 桁>`(同じ先頭の打ち直しは同じ id)。""" rid = f"{lane}-g{pr}-{stamp[:12]}" msg = {"kind": "gate1", "id": rid, "lane": lane, "issue": issue, "pr": pr, "report": report, "sprint_state": state, "stamp": stamp} - _send_inbox(main, name, {**msg, **extra, "at": _now()}, rid) + _send_inbox(main, name, {**msg, **extra, "at": _utc_stamp()}, rid) return rid @@ -274,7 +274,7 @@ def request_gate2(main: Path, name: str, n: int, k: int, report: str) -> str: sha = "" rid = f"{name}-p{n}-{k}" lane = prl.helper_name(name, n) - _send_inbox(main, name, {"kind": "gate2", "id": rid, "lane": lane, "n": n, "k": k, "report": report, "stamp": sha, "at": _now()}, rid) + _send_inbox(main, name, {"kind": "gate2", "id": rid, "lane": lane, "n": n, "k": k, "report": report, "stamp": sha, "at": _utc_stamp()}, rid) return rid @@ -288,5 +288,5 @@ def send_answer( stamp: Optional[str] = None, ) -> None: """オーガナイザーの答えを送る。耐久ワークフローが `answer-<依頼>` を 1 回だけ立て、問いの一覧から外す。""" - msg = {"kind": "answer", "id": rid, "result": result, "note": note, "choice": list(choice or []), "stamp": stamp, "at": _now()} + msg = {"kind": "answer", "id": rid, "result": result, "note": note, "choice": list(choice or []), "stamp": stamp, "at": _utc_stamp()} _send_inbox(main, name, msg, f"answer:{rid}") diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index fe40f4871..884873751 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -67,9 +67,6 @@ def checked_plan(main: Path, name: str) -> tuple[pp.ParallelPlan | None, list[di return pp.check_data(data) -# ---- 計画 ---- - - def cmd_init(a) -> None: main = main_of() try: diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json new file mode 100644 index 000000000..3c75e0f22 --- /dev/null +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json @@ -0,0 +1 @@ +{"path": "plugins/ndf/scripts/lib/parallel_run.py", "name": "", "kind": "lines", "reason": "#2436 で並列の計画の耐久ワークフロー(耐久ステップ・_Run の周回・一時レーンの起動・外の入口)を 1 モジュールに置いて 661 行。500 行以下にしたら消す。分けるなら、外の世界の読み出し(relay・base_tracking_ref・release_plugin・git_head・dev_version)と一時レーンの組み立て(helper_spawn・present_approval・helper_task)を別のモジュールへ移す", "lines": 661} diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop.py--lines.json new file mode 100644 index 000000000..8503ba47d --- /dev/null +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop.py--lines.json @@ -0,0 +1 @@ +{"path": "plugins/ndf/scripts/lib/sprint_loop.py", "name": "", "kind": "lines", "reason": "492 行だったところへ #2436(レーンの配布待ち・質問待ち・保留の周回の CLI)で増えて 584 行。500 行以下にしたら消す", "lines": 584} diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json new file mode 100644 index 000000000..cdc42cbf4 --- /dev/null +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json @@ -0,0 +1 @@ +{"path": "plugins/ndf/scripts/lib/sprint_loop_gate.py", "name": "", "kind": "lines", "reason": "498 行だったところへ #2436(承認ゲートの問いをオーガナイザーへ送る経路・レーンの配布待ち)で増えて 564 行。500 行以下にしたら消す。保留の周回は sprint_loop_gate2.py へ分けてある", "lines": 564} diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_record.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_record.py--lines.json new file mode 100644 index 000000000..182db35be --- /dev/null +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_record.py--lines.json @@ -0,0 +1 @@ +{"path": "plugins/ndf/scripts/lib/sprint_loop_record.py", "name": "", "kind": "lines", "reason": "483 行だったところへ #2436(レーンの配布待ち・質問待ちの記録と引継ぎ文書)で増えて 536 行。500 行以下にしたら消す", "lines": 536} From 0fe4a67260beb722ada50a0beff1b3dc99a6de01 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 08:34:48 +0000 Subject: [PATCH 41/70] =?UTF-8?q?Update:=20=E5=AE=A3=E8=A8=80=E3=81=AE?= =?UTF-8?q?=E6=95=B4=E5=BD=A2=E3=82=92=E5=BD=93=E3=81=A6=E3=82=8B=EF=BC=88?= =?UTF-8?q?lint-fix=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/ndf/scripts/lib/parallel_lane.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py index 39d2c90a5..b0987d74d 100644 --- a/plugins/ndf/scripts/lib/parallel_lane.py +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -274,7 +274,9 @@ def request_gate2(main: Path, name: str, n: int, k: int, report: str) -> str: sha = "" rid = f"{name}-p{n}-{k}" lane = prl.helper_name(name, n) - _send_inbox(main, name, {"kind": "gate2", "id": rid, "lane": lane, "n": n, "k": k, "report": report, "stamp": sha, "at": _utc_stamp()}, rid) + _send_inbox( + main, name, {"kind": "gate2", "id": rid, "lane": lane, "n": n, "k": k, "report": report, "stamp": sha, "at": _utc_stamp()}, rid + ) return rid From 92fd61bfc23274d1ee0369f92c43d08bac94b241 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 08:38:38 +0000 Subject: [PATCH 42/70] =?UTF-8?q?docs(spec):=20=E5=95=8F=E3=81=84=E3=81=AE?= =?UTF-8?q?=20hook=20=E3=81=8C=E3=83=AC=E3=83=BC=E3=83=B3=E3=81=AE?= =?UTF-8?q?=E5=95=8F=E3=81=84=E3=82=92=E3=82=AA=E3=83=BC=E3=82=AC=E3=83=8A?= =?UTF-8?q?=E3=82=A4=E3=82=B6=E3=83=BC=E3=81=B8=E9=80=81=E3=82=8B=E3=81=93?= =?UTF-8?q?=E3=81=A8=E3=81=A8=E3=80=81=E5=BE=85=E3=81=A1=E3=81=A0=E3=81=91?= =?UTF-8?q?=E3=81=A7=E6=AD=A2=E3=81=BE=E3=82=8B=20waiting=5Fonly=20?= =?UTF-8?q?=E3=82=92=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=AB=E6=9B=B8?= =?UTF-8?q?=E3=81=8F=EF=BC=88#2436=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 設計との突き合わせ(確認 (b))で、変更後の振る舞いと合わない記述を直した。 - ndf-ask-guard.md: 承認ゲートとレッドラインの問いも、並列の計画のレーンではオーガナイザーへ送れたときに deny(保留の案内)になる - ndf-backlog-refinement-depths.md / ndf-workflow-development-loop.md: 答えを待つ作業が残るときは見直しを挟まず waiting_only で止まる Co-Authored-By: Claude Opus 5.5 --- docs/specifications/ndf-ask-guard.md | 21 +++++++++++++++---- .../ndf-backlog-refinement-depths.md | 4 ++-- .../ndf-workflow-development-loop.md | 3 ++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/specifications/ndf-ask-guard.md b/docs/specifications/ndf-ask-guard.md index 6e3e8205d..f35224f23 100644 --- a/docs/specifications/ndf-ask-guard.md +++ b/docs/specifications/ndf-ask-guard.md @@ -15,6 +15,7 @@ Claude Code の `AskUserQuestion` を PreToolUse の hook(問い合わせの g | 5 | AI は A を選び、`ask.py decide --ref ae1533b36fcd --answer A --reason "…"` で代わりの判断の行を足して作業を続ける | 設計 Pull Request のマージの承認は、入力に `"metadata": {"source": "ndf-gate:1"}` を付ければ、どの宣言でも利用者へ出る。 +並列の計画のレーンと一時レーンでは、判定が通す問いを hook がオーガナイザーへ送り、オーガナイザーがまとめて利用者へ出す(下の「並列の計画のレーンの問い」)。 **手順・区分の当てはめ方・宣言の書き方は Skill が正である。** ここに書き写さない。 @@ -103,7 +104,7 @@ Claude Code の `AskUserQuestion` を PreToolUse の hook(問い合わせの g | --- | --- | | `plugins/ndf/scripts/lib/ask_policy.py` | 宣言の読み取りと検証(`load_ask_declaration`・`parse_declaration`)、根の決定(`project_root`)、区分の取り出しと解析(`source_of`・`parse_source`)、判定(`judge_ask`)、hook の入力からの判定(`judge_input`)、人へ回してよい区分の一覧(`passing_sources`)。標準ライブラリだけで書き、ファイルを読む以外の副作用を持たない | | `plugins/ndf/scripts/lib/ask_ledger.py` | 記録の置き場(`ask_ledger_path`)、行の組み立て(`verdict_row`・`decision_row`)、1 行の追記(`append_ask_row`)、読み出し(`read_ask_rows`)、判定の行の引き当て(`find_row`)、集計(`summarize_asks`)。標準ライブラリだけで書く | -| `plugins/ndf/scripts/hook_lib/ask_guard.py` | `ask_decision(ev)` が判定から hook の出力(`deny` の理由・`additionalContext` の案内)を作り、判定の行を追記する。追記の失敗は飲み、どの失敗も `None`(通す)を返す | +| `plugins/ndf/scripts/hook_lib/ask_guard.py` | `ask_decision(ev)` が判定から hook の出力(`deny` の理由・`additionalContext` の案内)を作り、判定の行を追記する。判定が通す問いを並列の計画のレーンか一時レーンが出したときは、`forward_to_organizer` が `parallel.py request-ask` でオーガナイザーへ送り、送れたら保留の手順を添えた `deny` を返す。追記と送りの失敗は飲み、どの失敗も `None`(通す)を返す | | `plugins/ndf/scripts/hook.py` の `_guards`・`dispatch` | `--runtime claude`・PreToolUse・`AskUserQuestion` のときだけ、締め切りの中で `ask_guard.ask_decision` を打つ(`Deadline.current` は `ask-guard`)。出力に `deny` があれば待ちの通知(`wait_notify.hook`)を打たない | | `plugins/ndf/scripts/ask.py` | 副命令 `decide`(代わりの判断の行を書く)と `summary`(数える)。出力は統一の JSON レポート(`lib/step_result.py`) | | `plugins/ndf/scripts/relay_lib/mark.py` | `question_denied(raw)` が入力を `judge_input` に掛け、`cmd_question("open")` は `deny` の問いにシグナルファイル(`question`)と時刻(`asked.json`)を書かない。差し戻しの文 `WAIT_REASON` が区分の付け方を案内する | @@ -215,8 +216,8 @@ classDiagram | # | 集約 | 条件 | 破れたときの扱い | | --- | --- | --- | --- | -| I1 | 問い合わせの判定 | 区分が `ndf-gate:1`・`ndf-gate:2` なら、どの宣言でも `deny` にならない | テストが落ちる | -| I2 | 問い合わせの判定 | 区分が `ndf-ask:redline:<番号>`(`C1`〜`C8`・`P<数>`・`R<数>`)なら、どの宣言でも `deny` にならない | テストが落ちる | +| I1 | 問い合わせの判定 | 区分が `ndf-gate:1`・`ndf-gate:2` なら、どの宣言でも `deny` にならない。例外は並列の計画のレーンか一時レーンの問いをオーガナイザーへ送れたときで、`deny` は利用者へ出さない理由ではなく保留の案内である | テストが落ちる | +| I2 | 問い合わせの判定 | 区分が `ndf-ask:redline:<番号>`(`C1`〜`C8`・`P<数>`・`R<数>`)なら、どの宣言でも `deny` にならない。例外は I1 と同じく、オーガナイザーへ送れたときである | テストが落ちる | | I3 | 問い合わせの判定 | `mode` が `enforce` のとき、区分が無い・形に合わない・許す区分(`allow` と `extra[].name`)に無い問いは `deny`、ある問いは `allow` になる | テストが落ちる | | I4 | 問い合わせの宣言 | 宣言が無い・読めない・形が誤っているとき、`enforce`・組み込みの 4 区分・しきい値 `0` で判定し、読めなかったときはその箇所(`problem`)を判定の出力と記録に添える | テストが落ちる | | I5 | 問い合わせの判定 | `mode` が `off` なら hook は何も出さず記録も書かない。`advise` なら `deny` を出さない | テストが落ちる | @@ -236,7 +237,7 @@ classDiagram | E1 | AI が `AskUserQuestion` を呼んだ | AI(Claude Code のセッション) | `hook.py` と relay の `question open`(PreToolUse) | — | | E2 | hook が宣言を読んだ | `ask_policy.load_ask_declaration` | `judge_ask` | 無い: 既定。読めない・誤り: 既定と箇所(I4) | | E3 | hook が問い合わせを通した(`allow`・`advise`) | `ask_guard.ask_decision` | Claude Code(問いを出す)・待ちの通知・relay(シグナルファイルを作る) | — | -| E4 | hook が問い合わせを止めた(`deny`) | `ask_guard.ask_decision` | AI(理由と案内を受け取る) | 内部の失敗は通す側へ倒す(I8) | +| E4 | hook が問い合わせを止めた(`deny`) | `ask_guard.ask_decision` | AI(理由と案内を受け取る。オーガナイザーへ送ったときは保留の手順) | 内部の失敗は通す側へ倒す(I8) | | E5 | hook が判定を記録に書いた | `ask_ledger.append_ask_row` | 振り返り・Factory のランナー | 書けなくても判定は変えない | | E6 | AI が止めた理由を受け取り、自分で判断した | AI | (E7 で記録に残る) | 区分を付け替えて出し直したら、hook は区分だけを見て判定する | | E7 | AI が判断と理由を記録した | `ask.py decide` | 振り返り・Factory のランナー | 記録しないまま進むと、E5 の止めた行だけが残り `unanswered` に数えられる | @@ -267,6 +268,18 @@ classDiagram `allow` の案内は 5 と 6 で、`kind` が `allowed`(宣言が許した区分)のときだけ先頭に「区分 `` で人へ回した。区分の 当てはめが誤っていれば取り下げて自分で決める」を足す。承認ゲートとレッドラインにはこの 1 行を付けない。 +### 並列の計画のレーンの問い + +判定が通す問い(`gate`・`redline`・`allowed`)のとき、hook は次の順で問いをオーガナイザーへ送るかを決める。 + +| 順 | 見ること | 当たったとき | +| --- | --- | --- | +| 1 | 環境変数 `NDF_RELAY_NAME` があり、メインディレクトリの `.ndf/parallel/*/plan.json` のどれかがその名前のレーンを持つか、作業ディレクトリ(`NDF_RELAY_DIR`)の役割のファイルが `helper` で計画名を持つか(`parallel_files.lane_name_in_plans`。ファイルだけを読み、耐久の記録は読まない) | 2 へ進む。当たらなければ今どおり通す | +| 2 | `parallel.py request-ask <計画名> --lane <名>` を子プロセスで打つ(上限 8 秒)。標準入力に問いの入力・作業の場所・判定の行の `id` を渡す | 終了コード 0 で依頼の `id` が返れば 3 へ。それ以外(レーンでも一時レーンでもない・計画が動いていない・上限を越えた)は今どおり通す | +| 3 | — | `deny` を返す。理由は「オーガナイザーへ送った(依頼 ``)。この作業を保留し、別の作業へ進め」と、開発ループのレーン(`sprint-state.py loop hold-question`)と 1 スプリントのレーン・一時レーン(引継ぎ文書に続きを書いてセッションを終える)の保留の手順、正本 `asking.md` の絶対パス | + +判定が止める問い(`deny`・`advise`)は送らない。保留の手順の正本は `asking.md` の「並列の計画のレーン」にある。 + ### `ask.py` | 副命令 | 入力 | 出力(標準出力の 1 行の統一 JSON レポート。`tool: ask`) | 終了コード | diff --git a/docs/specifications/ndf-backlog-refinement-depths.md b/docs/specifications/ndf-backlog-refinement-depths.md index 63877cf20..7d785593e 100644 --- a/docs/specifications/ndf-backlog-refinement-depths.md +++ b/docs/specifications/ndf-backlog-refinement-depths.md @@ -63,7 +63,7 @@ | 利用の記録は、スクリプトが宣言のコマンドを打ち、マスクと `secret_redact` を通した集計値だけを LLM へ渡す。宣言が無ければ何も読まない | 範囲の外を読まないことを、手順の文ではなくコードで守る。何を集計値とみなすかは、プロジェクトごとに機械で決められない | | 1 回の見直しの起票は既定 5 件まで。MVV 判定が「従う」でない課題には既存の `needs-decision` を付ける | 見直しの間の 3 周回で着手するのは 6〜9 件なので、起票が着手を上回らない。順位付けと選定は `needs-decision` をすでに外すので、新しい除外は要らない | | 見直しの候補は上限で切らず、更新の古い順に並べる | 持ち越しが約 160 件あり、10 件ずつでは追いつかない | -| 選べる課題が尽きたら、止まる前に見直しを 1 回だけ挟む(`review_first`) | 調査からの起票を試してから止まる。1 回に限るのは、起票が `needs-decision` に回り続けると見直しと選定の間を回り続けるため | +| 選べる課題が尽きたら、止まる前に見直しを 1 回だけ挟む(`review_first`)。答えを待つ作業(保留の周回か、オーガナイザーへ送って答えの無い設計の承認)が残るときは挟まず `waiting_only` で止まる | 調査からの起票を試してから止まる。1 回に限るのは、起票が `needs-decision` に回り続けると見直しと選定の間を回り続けるため | | 溜まり具合の閾値は open の増加 30%・持ち越しが上限の 10 倍・型の割合 30%・3 回の連続 | 1 本のループ(4 周回)と 2026-11-01 の分類(最多の型 A が不具合の 31%)から置いた値で、最初の 3 回の見直しの `depths.jsonl` で見直す | | 材料は `.ndf/project.json` の `materials`、頻度と上限は `.ndf/backlog.json` の `depths` に置く | 材料の宣言は解析の 3 手(measure → 答え → write)と「手で書いた値を変えない」規則を使える。頻度は `candidate_limit` と同じ棚卸しの調整の値である | | ライブラリは `seeds.libraries` に宣言したものだけを調べ、1 行を「変化」と「競合」の 2 つの項目にする | すべての依存を調べると、費用が依存の数に比例して増える | @@ -338,7 +338,7 @@ classDiagram | 差分の棚卸しは見直しの作業を反映しない。見直しの `apply` は持ち主が要る | `tests/test_upkeep_script.py`(`test_diff_depth_does_not_reflect_review_work` / `test_review_apply_needs_the_owner`) | | 起票は上限まで、ラベルと `needs-decision` つき | 同上の `test_review_creates_up_to_the_limit_with_labels_and_decision` | | 付け直しの前に前の状態を書き、戻せる。途中で失敗したら戻すコマンドを返す。大方針が従うまで付け直さない | 同上の `test_reparent_writes_before_state_and_reverts` / `test_reparent_failure_reports_the_revert_command` / `test_reparent_waits_for_a_following_policy` | -| 選べる課題が尽きた最初の選定は `review_first`、見直しの後も 0 件なら `no_issue` で止まる | `plugins/ndf/scripts/tests/test_sprint_loop.py`(`test_no_candidates_is_no_issue_and_all_excluded_is_outside_mvv`) | +| 選べる課題が尽きた最初の選定は `review_first`、見直しの後も 0 件なら `no_issue` で止まる。答えを待つ作業が残れば `waiting_only` で止まる | `plugins/ndf/scripts/tests/test_sprint_loop.py`(`test_no_candidates_is_no_issue_and_all_excluded_is_outside_mvv`) | | 周回の記録に深さの行と調査の費用が入り、調査の費用が予算に数えられる | 同上の `test_lap_records_depths_and_research_and_counts_research_in_the_budget` | | まとめのプランで、`refine` の次が `refine-record`、大整理 → 見直しの判定は 3 で飛び、持ち主になれないと `skipped`、本体の成否によらず `release` を通る | `plugins/ndf/scripts/tests/test_supervise_pace.py`(`test_close_runs_spec_retro_and_close_once_each_in_order`)・`test_supervise_pace_verify.py` | | `vet --kind issue` が課題の本文をプロジェクト MVV と照合する | `plugins/ndf/scripts/tests/test_project_mvv.py`(`test_vet_issue_judges_an_issue_body_against_the_project_mvv`) | diff --git a/docs/specifications/ndf-workflow-development-loop.md b/docs/specifications/ndf-workflow-development-loop.md index 006cce80c..1de7b05e5 100644 --- a/docs/specifications/ndf-workflow-development-loop.md +++ b/docs/specifications/ndf-workflow-development-loop.md @@ -185,7 +185,8 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 6. どのマイルストーンでも選べず、候補が 0 件なら `no_issue`、候補があってすべて外れたなら `outside_mvv` で止まる (`values` は対象のマイルストーンか理由ごとの件数)。候補が 0 件のときは、前の周回を締めてから見直しを挟んでいなければ止まらずに `status: review_first` を返し、 `next` に見直しの起動の判定(`upkeep.py depth due review --no-issue`)・見直し・選定のやり直しを置く。見直しの後も 0 件なら `no_issue` で止まる - (`lib/sprint_loop_select.py` の `review_first`。#2315) + (`lib/sprint_loop_select.py` の `review_first`。#2315)。答えを待つ作業(保留の周回か、オーガナイザーへ送った答えの無い設計の承認)が + 残るときは、見直しを挟まず `waiting_only` で止まる(下の「保留の周回(並列の計画のレーン)があるときの境の順」) `rank` の 20(段階の欠けた課題がある)は、欠けた課題が `rows` に載らないだけなので続ける。 From ef1465582146bcd3ddd0ed6fa0c7d7aa1aaecf9a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 08:44:54 +0000 Subject: [PATCH 43/70] =?UTF-8?q?docs:=20=E6=AF=94=E3=81=B9=E3=82=8B?= =?UTF-8?q?=E7=9B=B8=E6=89=8B=E3=81=AE=E8=AA=AC=E6=98=8E=E3=82=92=E4=BB=8A?= =?UTF-8?q?=E3=81=AE=E6=B1=BA=E3=81=BE=E3=82=8A=E3=81=A0=E3=81=91=E3=82=92?= =?UTF-8?q?=E6=9B=B8=E3=81=8F=E5=BD=A2=E3=81=AB=E6=8F=83=E3=81=88=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 --- docs/specifications/ndf-release-approved-commit.md | 2 +- docs/versioning-and-distribution.md | 6 +++--- plugins/ndf/skills/release/references/release-steps.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/specifications/ndf-release-approved-commit.md b/docs/specifications/ndf-release-approved-commit.md index 291774c2f..db67b6f27 100644 --- a/docs/specifications/ndf-release-approved-commit.md +++ b/docs/specifications/ndf-release-approved-commit.md @@ -53,7 +53,7 @@ ベースブランチ・本番チャネル・プラグイン名は宣言(`.ndf/worktree.json` の `base_branch`・`production_branch` と `.ndf/supervise.json` の `release.plugin`)と引数から読み、 `develop` / `main` を埋め込まない。 -**比べた先端は `origin/release/v<版>` の先端で、ベースブランチの先端ではない。** 本番のリリースプランは、「起点のコミット」が +**比べた先端は `origin/release/v<版>` の先端とする。** ベースブランチの先端は比べる相手に入れない。 本番のリリースプランは、「起点のコミット」が あればそのコミット(並列の計画のまとめた配布では承認したコミット)から、無ければ `origin/<ベースブランチ>` から `release/v<版>` を切り、 版上げのコミットを積む。`release/v<版>` を切った後にベースブランチへ入った変更は比べる相手に入らず、本番チャネルにも入らない。 その変更は次の配布で出る。 diff --git a/docs/versioning-and-distribution.md b/docs/versioning-and-distribution.md index f743e0bb5..989b2640d 100644 --- a/docs/versioning-and-distribution.md +++ b/docs/versioning-and-distribution.md @@ -140,7 +140,7 @@ ref の書き方は `owner/repo@ref` と `git-url#ref` の 2 つで、 `develop` か、先頭が `origin/develop` の祖先の `release/v<版>`。`scripts/check-pr-base.sh`)。 **承認の後に `develop` が進んでも、配布は止まらない。** 本番の配布(`release-steps.py release --channel prod`)が -承認したコミットと比べる相手は `origin/release/v<版>` の先端で、`develop` の先端ではない。`release/v<版>` に +承認したコミットと比べる相手は `origin/release/v<版>` の先端とし、`develop` の先端は比べる相手に入れない。`release/v<版>` に 承認したコミットと版上げの外のコミットがあれば、承認ゲート(終了コード 10)で止まる。承認の後に `develop` へ 入った変更は、この版の `main` には入らず次の配布で出る。 @@ -171,8 +171,8 @@ remote: `N-1 of N` になる。 **この手順では `main` に `develop` へ無いマージコミットが 1 つ積まれる。** そのため `main` は -`develop` の fast-forward から外れる。**それでよい。** リリースした版を指すのは `main` の先端で -あって、両者が同じコミットを指していることではない。中身が出した `release/v<版>` と一致していることは +`develop` の fast-forward から外れる。**それでよい。** リリースした版を指すのは `main` の先端である。 +`main` と `develop` が同じコミットを指すことは求めない。中身が出した `release/v<版>` と一致していることは 差分で確かめる(承認の後に `develop` が進んでいれば、`develop` とは一致しない)。 ```bash diff --git a/plugins/ndf/skills/release/references/release-steps.md b/plugins/ndf/skills/release/references/release-steps.md index a94cad483..1584c7e6d 100644 --- a/plugins/ndf/skills/release/references/release-steps.md +++ b/plugins/ndf/skills/release/references/release-steps.md @@ -181,7 +181,7 @@ python3 "$SCRIPTS/release-steps.py" release --version <版> --channel prod --app 承認したコミットと同じときだけ受け取り、違えば何もマージせずに `gate`(10・`result: not_approved`)で止まる。 配布の PR をマージした後、承認したコミットから `origin/release/v<版>` の先端(比べた先端)までに `release/v<版>` の Pull Request の外のコミットか中身があれば、本番チャネルの PR・タグ・GitHub Release を作らずに `gate`(10)で止まる。 - 承認の後に `develop` が進んでいても比べる相手ではないため止まらず、進んだ分は次の配布で出る。ただし差分がすべて許可リスト + 承認の後に `develop` が進んでいても、`develop` は比べる相手に入らないため止まらず、進んだ分は次の配布で出る。ただし差分がすべて許可リスト (`docs/`・`issues/`。`release_lib/approval.py` の `UNGATED`)の下に収まる外のコミット(設計文書・用語集だけの PR など)は止めずに通し、 `items[]` に `result: outside_unshipped` で、件数を `metrics.tolerated` に残す。本番チャネルへは比べた先端だけを マージし(`merged-steps.py merge-when-green --expect-head`)、CI 待ちの間に `release/v<版>` の先端が進んだときは比べ直す。進んだ分が From 5b7b01dd59dc23fa938f2cf9fbb15fd56e540d24 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 09:52:54 +0000 Subject: [PATCH 44/70] =?UTF-8?q?Fix:=20sprint-state.py=20=E3=81=AE=20deps?= =?UTF-8?q?.require=20=E3=81=AB=20durable=E3=83=BBversions=20=E3=82=92?= =?UTF-8?q?=E8=B6=B3=E3=81=97=E3=80=81ndf-ask-guard.md=20=E3=82=92=20500?= =?UTF-8?q?=20=E8=A1=8C=E3=81=AB=E5=8F=8E=E3=82=81=E3=82=8B=EF=BC=88#2436?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KYHP819RPG1z3k6oVRWvoD --- docs/specifications/ndf-ask-guard.md | 13 ++----------- plugins/ndf/scripts/sprint-state.py | 2 +- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/docs/specifications/ndf-ask-guard.md b/docs/specifications/ndf-ask-guard.md index f35224f23..51181368e 100644 --- a/docs/specifications/ndf-ask-guard.md +++ b/docs/specifications/ndf-ask-guard.md @@ -14,8 +14,7 @@ Claude Code の `AskUserQuestion` を PreToolUse の hook(問い合わせの g | 4 | relay の `question open` も同じ判定で `deny` を得て、質問のシグナルファイルを作らない | | 5 | AI は A を選び、`ask.py decide --ref ae1533b36fcd --answer A --reason "…"` で代わりの判断の行を足して作業を続ける | -設計 Pull Request のマージの承認は、入力に `"metadata": {"source": "ndf-gate:1"}` を付ければ、どの宣言でも利用者へ出る。 -並列の計画のレーンと一時レーンでは、判定が通す問いを hook がオーガナイザーへ送り、オーガナイザーがまとめて利用者へ出す(下の「並列の計画のレーンの問い」)。 +設計 Pull Request のマージの承認は、入力に `"metadata": {"source": "ndf-gate:1"}` を付ければ、どの宣言でも利用者へ出る。並列の計画のレーンと一時レーンでは、判定が通す問いを hook がオーガナイザーへ送り、オーガナイザーがまとめて利用者へ出す(下の「並列の計画のレーンの問い」)。 **手順・区分の当てはめ方・宣言の書き方は Skill が正である。** ここに書き写さない。 @@ -270,15 +269,7 @@ classDiagram ### 並列の計画のレーンの問い -判定が通す問い(`gate`・`redline`・`allowed`)のとき、hook は次の順で問いをオーガナイザーへ送るかを決める。 - -| 順 | 見ること | 当たったとき | -| --- | --- | --- | -| 1 | 環境変数 `NDF_RELAY_NAME` があり、メインディレクトリの `.ndf/parallel/*/plan.json` のどれかがその名前のレーンを持つか、作業ディレクトリ(`NDF_RELAY_DIR`)の役割のファイルが `helper` で計画名を持つか(`parallel_files.lane_name_in_plans`。ファイルだけを読み、耐久の記録は読まない) | 2 へ進む。当たらなければ今どおり通す | -| 2 | `parallel.py request-ask <計画名> --lane <名>` を子プロセスで打つ(上限 8 秒)。標準入力に問いの入力・作業の場所・判定の行の `id` を渡す | 終了コード 0 で依頼の `id` が返れば 3 へ。それ以外(レーンでも一時レーンでもない・計画が動いていない・上限を越えた)は今どおり通す | -| 3 | — | `deny` を返す。理由は「オーガナイザーへ送った(依頼 ``)。この作業を保留し、別の作業へ進め」と、開発ループのレーン(`sprint-state.py loop hold-question`)と 1 スプリントのレーン・一時レーン(引継ぎ文書に続きを書いてセッションを終える)の保留の手順、正本 `asking.md` の絶対パス | - -判定が止める問い(`deny`・`advise`)は送らない。保留の手順の正本は `asking.md` の「並列の計画のレーン」にある。 +判定が通す問い(`gate`・`redline`・`allowed`)を並列の計画のレーンか一時レーン(`NDF_RELAY_NAME` が計画のレーンの名前か、役割のファイルが `helper`。`parallel_files.lane_name_in_plans` がファイルだけを読んで決める)が出したとき、hook は `parallel.py request-ask <計画名> --lane <名>` を上限 8 秒で打ってオーガナイザーへ送り、依頼の `id` が返れば保留の手順を添えた `deny` を返す。送れなければ今どおり通す。止める問い(`deny`・`advise`)は送らない。保留の手順の正本は `asking.md` の「並列の計画のレーン」にある。 ### `ask.py` diff --git a/plugins/ndf/scripts/sprint-state.py b/plugins/ndf/scripts/sprint-state.py index da355d356..8a69738e6 100755 --- a/plugins/ndf/scripts/sprint-state.py +++ b/plugins/ndf/scripts/sprint-state.py @@ -37,7 +37,7 @@ import supervise_lib # noqa: E402,F401 lib/ を sys.path へ足す import deps # noqa: E402 -deps.require("md", "mdtable", "schema", "locks") # schema は supervise_lib.paths → decl が使う +deps.require("md", "mdtable", "schema", "locks", "durable", "versions") # schema は supervise_lib.paths → decl が使う import report_fields # noqa: E402 from supervise_lib.paths import sha256_of, state_dir_of # noqa: E402 import clock # noqa: E402 From 73c1e44304c3cdbff61c5e5779df859d8d11be61 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 10:21:26 +0000 Subject: [PATCH 45/70] =?UTF-8?q?Test:=20test=5Fparallel=5Frun=20=E3=81=AE?= =?UTF-8?q?=E5=BE=8C=E7=89=87=E4=BB=98=E3=81=91=E3=81=A7=E8=80=90=E4=B9=85?= =?UTF-8?q?=E3=83=AF=E3=83=BC=E3=82=AF=E3=83=95=E3=83=AD=E3=83=BC=E3=82=92?= =?UTF-8?q?=E6=AD=A2=E3=82=81=E3=80=81=E6=AC=A1=E3=81=AE=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=81=AE=E5=90=8C=E3=81=98=20ID=20=E3=81=AE=E8=B5=B7?= =?UTF-8?q?=E5=8B=95=E3=81=8C=E9=87=8D=E8=A4=87=E3=81=A7=E6=89=93=E3=81=A1?= =?UTF-8?q?=E5=88=87=E3=82=89=E3=82=8C=E3=81=AA=E3=81=84=E3=82=88=E3=81=86?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B=EF=BC=88#2436=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KYHP819RPG1z3k6oVRWvoD --- plugins/ndf/scripts/tests/test_parallel_run.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index 060945770..8f14dc199 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -79,9 +79,27 @@ def builder(_main, _name, d, _v): monkeypatch.setattr(prl, "build_plans", lambda m, n, s, k, v, builder_=None: _build(m, n, s, k, v, builder)) yield {"main": main, "log": log, "states": states, "heads": heads} + _stop_running(main) durable.close() +def _stop_running(main): + """テストが残した耐久ワークフローを止め、スレッドが抜けるのを待つ。 + + close() は DBOS を止めてもワークフローのスレッドを止めない。残すと次のテストの同じ ID(parallel-p1)の起動が + 重複として打ち切られ、前のテストのスレッドが次のテストの記録を読み書きする。""" + wid = pl.workflow_id("p1") + try: + if durable.status(wid) not in pl.RUNNING: + return + durable.send_to(pl.KIND, pl.record_identity(main, "p1"), wid, {"kind": "stop", "force": True}, "inbox", f"{wid}:inbox:stop:force") + end = time.monotonic() + 15 + while time.monotonic() < end and durable.status(wid) in pl.RUNNING: + time.sleep(0.1) + except Exception: # 後片付けの失敗でテストの結果を変えない + pass + + def _build(m, n, s, k, v, builder): return prl.__dict__["_orig_build"](m, n, s, k, v, builder) From 5c40f1c22085dcca4ee7db9d89a55a73df48c717 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 10:35:34 +0000 Subject: [PATCH 46/70] =?UTF-8?q?docs:=20=E6=9B=B8=E3=81=8D=E6=96=B9?= =?UTF-8?q?=E3=81=AE=E3=83=81=E3=82=A7=E3=83=83=E3=82=AF=E3=81=AB=E5=BD=93?= =?UTF-8?q?=E3=81=9F=E3=81=A3=E3=81=9F=202=20=E8=A1=8C=E3=82=92=E4=BB=8A?= =?UTF-8?q?=E3=81=AE=E6=B1=BA=E3=81=BE=E3=82=8A=E3=81=A0=E3=81=91=E3=81=AE?= =?UTF-8?q?=E5=BD=A2=E3=81=AB=E3=81=99=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 --- docs/specifications/ndf-ask-guard.md | 2 +- plugins/ndf/skills/development-workflow/references/loop.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specifications/ndf-ask-guard.md b/docs/specifications/ndf-ask-guard.md index 51181368e..23a30e4fb 100644 --- a/docs/specifications/ndf-ask-guard.md +++ b/docs/specifications/ndf-ask-guard.md @@ -215,7 +215,7 @@ classDiagram | # | 集約 | 条件 | 破れたときの扱い | | --- | --- | --- | --- | -| I1 | 問い合わせの判定 | 区分が `ndf-gate:1`・`ndf-gate:2` なら、どの宣言でも `deny` にならない。例外は並列の計画のレーンか一時レーンの問いをオーガナイザーへ送れたときで、`deny` は利用者へ出さない理由ではなく保留の案内である | テストが落ちる | +| I1 | 問い合わせの判定 | 区分が `ndf-gate:1`・`ndf-gate:2` なら、どの宣言でも `deny` にならない。例外は並列の計画のレーンか一時レーンの問いをオーガナイザーへ送れたときで、このときの `deny` は保留の案内である | テストが落ちる | | I2 | 問い合わせの判定 | 区分が `ndf-ask:redline:<番号>`(`C1`〜`C8`・`P<数>`・`R<数>`)なら、どの宣言でも `deny` にならない。例外は I1 と同じく、オーガナイザーへ送れたときである | テストが落ちる | | I3 | 問い合わせの判定 | `mode` が `enforce` のとき、区分が無い・形に合わない・許す区分(`allow` と `extra[].name`)に無い問いは `deny`、ある問いは `allow` になる | テストが落ちる | | I4 | 問い合わせの宣言 | 宣言が無い・読めない・形が誤っているとき、`enforce`・組み込みの 4 区分・しきい値 `0` で判定し、読めなかったときはその箇所(`problem`)を判定の出力と記録に添える | テストが落ちる | diff --git a/plugins/ndf/skills/development-workflow/references/loop.md b/plugins/ndf/skills/development-workflow/references/loop.md index 8d9653aab..a68e1346b 100644 --- a/plugins/ndf/skills/development-workflow/references/loop.md +++ b/plugins/ndf/skills/development-workflow/references/loop.md @@ -72,7 +72,7 @@ 設計 PR がマージされた後の周回で選定の対象に戻す(その課題は設計のステージを置かない)。`--rejected` は差し戻しの記録を書き、 以後選ばない。答えるまで、その課題は選ばない。 -`--revise --note <指摘ファイル>` は「直して出し直す」で、指摘を課題の本文の節に書き、次の周回で順位によらず先頭に選ぶ。 +`--revise --note <指摘ファイル>` は「直して出し直す」で、指摘を課題の本文の節に書き、次の周回で先頭に選ぶ。 **スプリント MVV を持つマイルストーンは、利用者が承認したときだけ選ぶ。** 承認は `$SS loop approve-mvv <名> --milestone <題>` で、説明の Mission / Vision / Value の sha256 を記録する。説明が変われば承認し直す。 From 6552e4eb64fbef14cb69b1ea9f3b921c72078bb0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 11:10:35 +0000 Subject: [PATCH 47/70] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/scripts/lib/parallel=5Frun.py#parallel=5Fworkflo?= =?UTF-8?q?w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parallel_workflow のループから 3 つを抽出する。 - _recv_resume: resume を最大 30 秒待ち、届いたら run.resume する(stop の待ちと配布中の待ちの 2 か所で重複していた) - _finish_or_flush_held: 全レーンが止まった後、保留の周回が無ければ要約を返し、あれば一時レーンを 1 本起こして None を返す - _Run.release_pending: 配布が流れていなければ範囲で答えてから配布を開く 耐久ステップの呼び出しの順序と回数は変えない。抽出で 15 行増えたため、行数の例外リストを 676 行へ更新する。 Item-Id: I-001 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_run.py | 55 ++++++++++++------- ..._scripts__lib__parallel_run.py--lines.json | 2 +- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index f98be2ff9..f398258c7 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -348,6 +348,13 @@ def answer_from_ranges(self) -> None: self.answer(r["id"], "released", rel.get("version"), rel["n"]) self.pending = [r for r in self.pending if r["id"] not in hit] + def release_pending(self) -> None: + """配布が流れていなければ、出した範囲で答えてから残りの依頼で配布を開く。""" + if self.current is None and self.pending: + self.answer_from_ranges() + if self.pending: + self.open_release() + def open_release(self) -> None: """流れている配布が無く依頼があれば、他のレーンを待たずに提示 1 を組む(I9)。""" n = len(self.releases) + 1 @@ -500,34 +507,42 @@ def parallel_workflow(main: str, name: str) -> dict: return _handle_stop(run, main) if run.stop: # 一時レーンが配布を流している間(提示のまま MVV 判定で本番へ進む場合も含む)の stop は、 # settle が届くまで待ってから終える(設計の失敗の 12 番。作りかけを残さない) - msg = durable.recv("resume", min(wait, 30)) - if isinstance(msg, dict): - run.resume(msg) + _recv_resume(run, wait) continue _spawn_ready(run, main) - if run.current is None and run.pending: - run.answer_from_ranges() - if run.pending: - run.open_release() + run.release_pending() if _run_finished(run): - held = step_held_loops(main, name, sorted(run.answered)) - if not held: - step_finish(main, name, run.progress()) - return run.summary("finished") - # 止まったレーンの保留の周回のまとめを一時レーンで流してから終える(異常系 11)。一時レーンは 1 本ずつ起こす - todo = [x for x in held if x not in run.held_spawned] - if todo and not set(run.held_spawned) & set(held): - run.held_spawned.append(todo[0]) - seq = len(run.releases) + len(run.held_spawned) - run.pause("held", loops=[todo[0]], spawn=helper_spawn(name, seq, 0, pending=todo[0])) + done = _finish_or_flush_held(run, main, name) + if done is not None: + return done run.take(durable.recv("inbox", wait if run.current is None else min(wait, 30))) if run.current is not None: - msg = durable.recv("resume", min(wait, 30)) - if isinstance(msg, dict): - run.resume(msg) + _recv_resume(run, wait) _observe_run(run, main) +def _recv_resume(run: _Run, wait: float) -> None: + """一時レーンからの再開の知らせを最大 30 秒待ち、届いたら反映する。""" + msg = durable.recv("resume", min(wait, 30)) + if isinstance(msg, dict): + run.resume(msg) + + +def _finish_or_flush_held(run: _Run, main: str, name: str) -> Optional[dict]: + """保留の周回が無ければ記録を閉じて要約を返す。あれば一時レーンを 1 本起こして None を返す。""" + held = step_held_loops(main, name, sorted(run.answered)) + if not held: + step_finish(main, name, run.progress()) + return run.summary("finished") + # 止まったレーンの保留の周回のまとめを一時レーンで流してから終える(異常系 11)。一時レーンは 1 本ずつ起こす + todo = [x for x in held if x not in run.held_spawned] + if todo and not set(run.held_spawned) & set(held): + run.held_spawned.append(todo[0]) + seq = len(run.releases) + len(run.held_spawned) + run.pause("held", loops=[todo[0]], spawn=helper_spawn(name, seq, 0, pending=todo[0])) + return None + + def _started(run: _Run) -> list[str]: return [n for n, st in run.lanes.items() if st.get("started")] diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json index 3c75e0f22..04301832a 100644 --- a/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/scripts/lib/parallel_run.py", "name": "", "kind": "lines", "reason": "#2436 で並列の計画の耐久ワークフロー(耐久ステップ・_Run の周回・一時レーンの起動・外の入口)を 1 モジュールに置いて 661 行。500 行以下にしたら消す。分けるなら、外の世界の読み出し(relay・base_tracking_ref・release_plugin・git_head・dev_version)と一時レーンの組み立て(helper_spawn・present_approval・helper_task)を別のモジュールへ移す", "lines": 661} +{"path": "plugins/ndf/scripts/lib/parallel_run.py", "name": "", "kind": "lines", "reason": "#2436 で並列の計画の耐久ワークフロー(耐久ステップ・_Run の周回・一時レーンの起動・外の入口)を 1 モジュールに置いて 676 行。500 行以下にしたら消す。分けるなら、外の世界の読み出し(relay・base_tracking_ref・release_plugin・git_head・dev_version)と一時レーンの組み立て(helper_spawn・present_approval・helper_task)を別のモジュールへ移す", "lines": 676} From 953e049a013f3f3fa7fb0a014c030620354a1c66 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 11:12:51 +0000 Subject: [PATCH 48/70] =?UTF-8?q?Refactor:=20fix=5Fdependency=5Fdirection?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/scripts/lib/sprint=5Floop=5Fselect.p?= =?UTF-8?q?y#boundary=5Fthen=5Fselect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下の部品の sprint_loop_select が関数の中で上の入口 sprint_loop を import し、 sprint_loop.apply_gate1_answers を pending_step の引数で注入していた。 apply_answer・apply_gate1_answers を sprint_loop_gate2 へ移し、pending_step は apply_gate1_answers を直に呼ぶ。本番の呼び出し元は boundary_then_select の 1 つだけで、 常に注入していたため振る舞いは変わらない。sprint_loop.cmd_answer は sprint_loop_gate2 から apply_answer を読み込む。使われなくなった GATE_1・mark_issue の import を外した。 Item-Id: I-002 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/sprint_loop.py | 56 +---------------- plugins/ndf/scripts/lib/sprint_loop_gate2.py | 60 ++++++++++++++++++- plugins/ndf/scripts/lib/sprint_loop_select.py | 4 +- 3 files changed, 59 insertions(+), 61 deletions(-) diff --git a/plugins/ndf/scripts/lib/sprint_loop.py b/plugins/ndf/scripts/lib/sprint_loop.py index 37fc609c5..8c8ac8b68 100644 --- a/plugins/ndf/scripts/lib/sprint_loop.py +++ b/plugins/ndf/scripts/lib/sprint_loop.py @@ -33,7 +33,6 @@ RETURNED, QUESTION_PENDING, PENDING, - GATE_1, FINISHED, NOT_RUN, LoopError, @@ -69,7 +68,6 @@ read_done_items, continuation, CONTINUATION_STEP, - mark_issue, refresh_state, handle_gate, failure_key, @@ -79,7 +77,7 @@ owner_line, ) -from sprint_loop_gate2 import finish_held, gate2_answer, gate2_pipeline, pending_pipeline +from sprint_loop_gate2 import apply_answer, finish_held, gate2_answer, gate2_pipeline, pending_pipeline # ---------------------------------------------------------------- start @@ -379,58 +377,6 @@ def cmd_answer(env: Env, name: str, number: int, approved: bool, pr: int | None, return loop_ok(f"#{number} の答え({w['answer']})を書いた。{after}", rec, [w, *({"note": n} for n in notes)], status="answered") -def apply_answer(env: Env, rec: dict, w: dict, answer: str, pr: int | None, note: str | None = None) -> list[str]: - """人の判断待ちの行へ答えを書き、スプリント状態の承認の記録と課題の本文の節を更新する。書けなかった理由を返す。 - 直して出し直す(revise)は答えの確定でないため承認の記録を書かず、指摘と回数を行と本文に書く(#2436 の 6e)。""" - w.update(answer=answer, answered_at=env.now()) - if pr: - w["pr"] = pr - notes: list[str] = [] - if answer == "revise": - w["note"] = note or "" - w["revise_count"] = sum(1 for x in rec.get("waiting", []) if x["number"] == w["number"] and x.get("answer") == "revise") - if err := mark_issue(env, rec, w): - notes.append(err) - return notes - lap = rec["laps"][w["lap"] - 1] - state = lap.get("state_path") - if state: - cmd = [ - sys.executable, - str(SPRINT_STATE), - "gate", - state, - GATE_1, - "--by", - "user", - "--what", - f"開発ループ {rec['name']} の #{w['number']} の設計", - ] - cmd += ["--outcome", w["answer"], "--root", str(env.main_dir())] + (["--pr", str(w["pr"])] if w.get("pr") else []) - code, so, se = env.run(cmd, cwd=str(env.main_dir())) - if code != 0: - notes.append(f"承認の記録を書けない({code}): {(se or so).strip()[:200]}") - if err := mark_issue(env, rec, w): - notes.append(err) - return notes - - -def apply_gate1_answers(env: Env, rec: dict, answers: dict) -> None: - """オーガナイザーへ送った設計の承認の答えを、持ち主のレーンが周回の境で処理する(#2436 の 6e・6f)。 - 承認ならラベル design-approved を付けて承認を書き、直して出し直すなら指摘を、やらないなら差し戻しを書く。""" - for w in [w for w in rec.get("waiting", []) if w.get("request") and not w.get("answer")]: - a = (answers.get(w["request"]) or {}).get("answer") - if not isinstance(a, dict): - continue - result = a.get("result") - if result == "approved" and w.get("pr"): - code, so, se = env.run(["gh", "pr", "edit", str(w["pr"]), "--add-label", "design-approved"], cwd=str(env.main_dir())) - if code != 0: - w["label_error"] = (se or so).strip()[:200] - if result in ("approved", "rejected", "revise"): - apply_answer(env, rec, w, result, w.get("pr"), a.get("note")) - - def cmd_approve_mvv(env: Env, name: str, milestone: str) -> dict: with open_record(env, name) as (rec, before): try: diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate2.py b/plugins/ndf/scripts/lib/sprint_loop_gate2.py index 42acf2599..b3ed94e1c 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate2.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate2.py @@ -6,6 +6,7 @@ from __future__ import annotations import shlex +import sys from pathlib import Path import clock @@ -13,6 +14,7 @@ import jsonio from sprint_loop_record import ( + GATE_1, GATE_2, SPRINT_STATE, SUPERVISE, @@ -31,6 +33,7 @@ finish_lap, gate2_resume_step, loop_ok, + mark_issue, plan_info, queue_command, stages_after, @@ -187,11 +190,62 @@ def settle_release(env: Env, rec: dict, lap: dict, a: dict) -> None: lap["failure"] = {"issues": lap.get("issues_kept") or selected_issues(lap), "kind": "配布", "step": "配布の失敗"} -def pending_step(env: Env, rec: dict, before: dict, apply_gate1=None) -> dict | None: +def apply_answer(env: Env, rec: dict, w: dict, answer: str, pr: int | None, note: str | None = None) -> list[str]: + """人の判断待ちの行へ答えを書き、スプリント状態の承認の記録と課題の本文の節を更新する。書けなかった理由を返す。 + 直して出し直す(revise)は答えの確定でないため承認の記録を書かず、指摘と回数を行と本文に書く(#2436 の 6e)。""" + w.update(answer=answer, answered_at=env.now()) + if pr: + w["pr"] = pr + notes: list[str] = [] + if answer == "revise": + w["note"] = note or "" + w["revise_count"] = sum(1 for x in rec.get("waiting", []) if x["number"] == w["number"] and x.get("answer") == "revise") + if err := mark_issue(env, rec, w): + notes.append(err) + return notes + lap = rec["laps"][w["lap"] - 1] + state = lap.get("state_path") + if state: + cmd = [ + sys.executable, + str(SPRINT_STATE), + "gate", + state, + GATE_1, + "--by", + "user", + "--what", + f"開発ループ {rec['name']} の #{w['number']} の設計", + ] + cmd += ["--outcome", w["answer"], "--root", str(env.main_dir())] + (["--pr", str(w["pr"])] if w.get("pr") else []) + code, so, se = env.run(cmd, cwd=str(env.main_dir())) + if code != 0: + notes.append(f"承認の記録を書けない({code}): {(se or so).strip()[:200]}") + if err := mark_issue(env, rec, w): + notes.append(err) + return notes + + +def apply_gate1_answers(env: Env, rec: dict, answers: dict) -> None: + """オーガナイザーへ送った設計の承認の答えを、持ち主のレーンが周回の境で処理する(#2436 の 6e・6f)。 + 承認ならラベル design-approved を付けて承認を書き、直して出し直すなら指摘を、やらないなら差し戻しを書く。""" + for w in [w for w in rec.get("waiting", []) if w.get("request") and not w.get("answer")]: + a = (answers.get(w["request"]) or {}).get("answer") + if not isinstance(a, dict): + continue + result = a.get("result") + if result == "approved" and w.get("pr"): + code, so, se = env.run(["gh", "pr", "edit", str(w["pr"]), "--add-label", "design-approved"], cwd=str(env.main_dir())) + if code != 0: + w["label_error"] = (se or so).strip()[:200] + if result in ("approved", "rejected", "revise"): + apply_answer(env, rec, w, result, w.get("pr"), a.get("note")) + + +def pending_step(env: Env, rec: dict, before: dict) -> dict | None: """周回の境で、答えの届いた依頼を古い順に扱う(境の 2〜6)。まとめを流す周回があればその結果を返し、無ければ None。""" answers = read_answers(env, rec) - if apply_gate1 is not None: - apply_gate1(env, rec, answers) + apply_gate1_answers(env, rec, answers) for lap in pending_laps(rec): a = answer_of(answers, (lap.get("release") or {}).get("request")) if lap["outcome"] == RELEASE_PENDING else None if a and a.get("result") != "released": diff --git a/plugins/ndf/scripts/lib/sprint_loop_select.py b/plugins/ndf/scripts/lib/sprint_loop_select.py index 889a0baba..c25f6b4c8 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_select.py +++ b/plugins/ndf/scripts/lib/sprint_loop_select.py @@ -194,9 +194,7 @@ def boundary_then_select(env: Env, rec: dict, before: dict) -> dict: stop = boundary_stop(env, rec) if stop and stop["condition"] in IMMEDIATE_STOPS: return stop_and_report(env, rec, before, stop, f"止まる条件に当たった({stop['condition']})") - import sprint_loop - - got = pending_step(env, rec, before, sprint_loop.apply_gate1_answers) + got = pending_step(env, rec, before) if got is not None: return got if len(pending_laps(rec)) >= rec.get("release_pending_max", RELEASE_PENDING_MAX): From 2dc634d236e02847cac3b457c5bb8f3aa13617b4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 11:14:12 +0000 Subject: [PATCH 49/70] =?UTF-8?q?Refactor:=20centralize=5Fconfiguration=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/lib/sprint=5Floop=5Fgate.py#REL?= =?UTF-8?q?EASE=5FREQUEST=5FPHASE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配布の依頼のプランの「フェーズ」の値を pydantic を読まない軽い部品 parallel_files.py へ 1 つ置き、 書く側の parallel_release.py と読む側の sprint_loop_gate.py はそこから import する。 sprint_loop_gate2.finish_held の関数内 import は同じ名前のまま通る。 Item-Id: I-003 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_files.py | 1 + plugins/ndf/scripts/lib/parallel_release.py | 3 +-- plugins/ndf/scripts/lib/sprint_loop_gate.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_files.py b/plugins/ndf/scripts/lib/parallel_files.py index 1e0e52593..e0664eca4 100644 --- a/plugins/ndf/scripts/lib/parallel_files.py +++ b/plugins/ndf/scripts/lib/parallel_files.py @@ -12,6 +12,7 @@ PARALLEL_DIR = Path(".ndf") / "parallel" ROLE_FILE = "role.json" # 統括役の作業ディレクトリの役割のファイル(relay_lib.hub.ROLE_FILE と同じ公開の形。統括役を import しない) +RELEASE_REQUEST_PHASE = "配布の依頼" # 配布の依頼のプランの「フェーズ」(書く parallel_release と読む sprint_loop_gate が共有する) def plans_in(main: Path) -> list[str]: diff --git a/plugins/ndf/scripts/lib/parallel_release.py b/plugins/ndf/scripts/lib/parallel_release.py index 999598ffd..27fb3de09 100644 --- a/plugins/ndf/scripts/lib/parallel_release.py +++ b/plugins/ndf/scripts/lib/parallel_release.py @@ -15,6 +15,7 @@ from typing import Callable, Iterable, Optional import parallel_plan as pp +from parallel_files import RELEASE_REQUEST_PHASE MAX_HELPER_SEQ = 9999 # 一時レーンの名前 `<計画名>-rel<連番>` を 32 字に収める(I1) @@ -232,8 +233,6 @@ def present_gate_steps(state: str, repo: str | None, approval: str, prs: str, mo return steps -RELEASE_REQUEST_PHASE = "配布の依頼" - def plan_release_request(a, repo: str, plan_name: str, lane: str, prs: list[str] | None = None) -> dict: """並列の計画のレーンの本番のステージ(#2436 の F6)。本番を流さず、オーガナイザーへ配布の依頼を送る。 diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index 38a4d1ef1..1733483e6 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -15,6 +15,7 @@ import jsonio import project_mvv as pm import step_result +from parallel_files import RELEASE_REQUEST_PHASE from sprint_loop_record import ( TOOL, @@ -296,8 +297,6 @@ def handle_gate2(env: Env, rec: dict, before: dict, lap: dict, plan: str | None return stop_and_report(env, rec, before, stop, "承認ゲート 2 を人へ戻した。本番を出さずに止まる(利用者の承認を求める)", {"gate": 2}) -RELEASE_REQUEST_PHASE = "配布の依頼" # parallel_release.RELEASE_REQUEST_PHASE(配布の依頼のプランの「フェーズ」) - def request_record(plan: str) -> tuple[Path, dict]: """配布の依頼のプランの状態ディレクトリの `work/request.txt`(`parallel.py request` が書く)のパスと中身。""" From 66ff5c40b64fb663dc6c6553f698f2639d0513ac Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 11:15:21 +0000 Subject: [PATCH 50/70] =?UTF-8?q?Fix:=20=E6=A4=9C=E8=A8=BC=E3=81=AE?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=82=92=E7=9B=B4=E3=81=99=20=E2=80=94=20plu?= =?UTF-8?q?gins/ndf/scripts/lib/sprint=5Floop=5Fgate.py#RELEASE=5FREQUEST?= =?UTF-8?q?=5FPHASE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 定義を消した跡に残った余分な空行(関数の間が 3 行)を消し、ruff format --check を通す。 Item-Id: I-003 Impl-Runtime: claude Impl-Model: default --- plugins/ndf/scripts/lib/parallel_release.py | 1 - plugins/ndf/scripts/lib/sprint_loop_gate.py | 1 - 2 files changed, 2 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_release.py b/plugins/ndf/scripts/lib/parallel_release.py index 27fb3de09..de0362b57 100644 --- a/plugins/ndf/scripts/lib/parallel_release.py +++ b/plugins/ndf/scripts/lib/parallel_release.py @@ -233,7 +233,6 @@ def present_gate_steps(state: str, repo: str | None, approval: str, prs: str, mo return steps - def plan_release_request(a, repo: str, plan_name: str, lane: str, prs: list[str] | None = None) -> dict: """並列の計画のレーンの本番のステージ(#2436 の F6)。本番を流さず、オーガナイザーへ配布の依頼を送る。 diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index 1733483e6..a26255fa7 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -297,7 +297,6 @@ def handle_gate2(env: Env, rec: dict, before: dict, lap: dict, plan: str | None return stop_and_report(env, rec, before, stop, "承認ゲート 2 を人へ戻した。本番を出さずに止まる(利用者の承認を求める)", {"gate": 2}) - def request_record(plan: str) -> tuple[Path, dict]: """配布の依頼のプランの状態ディレクトリの `work/request.txt`(`parallel.py request` が書く)のパスと中身。""" out = Path(plan).with_name(Path(plan).stem + "-state") / "work" / "request.txt" From 0499498f613697a513b23bdb97ed7edb133b2f29 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 11:16:27 +0000 Subject: [PATCH 51/70] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/scripts/lib/sprint=5Floop=5Fselect.py#choose=5Fi?= =?UTF-8?q?ssues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 候補に直しの先頭課題を補う処理を _candidates_with_first へ、境界外の行を DROPPED の 選定の行にする処理を _outside_boundary_rows へ抽出した。choose_issues にはマイルストーンの 順序、MVV の承認照合、最初に選べた候補で終える制御だけを残す。reason_for の呼び出し回数と 順序、selection・candidates の値は変わらない。 Item-Id: I-004 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/sprint_loop_select.py | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/plugins/ndf/scripts/lib/sprint_loop_select.py b/plugins/ndf/scripts/lib/sprint_loop_select.py index c25f6b4c8..2d43f5835 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_select.py +++ b/plugins/ndf/scripts/lib/sprint_loop_select.py @@ -143,6 +143,23 @@ def _select_candidate_rows( return picked, here +def _candidates_with_first(ms: dict, max_issues: int, held: set[int], first: set[int]) -> list[dict]: + """マイルストーンの候補(candidates_of)に、候補に無い先頭に選ぶ課題(first)を順位の順に後ろへ足す。""" + cands = candidates_of(ms, max_issues, held) + have = {r["number"] for r in cands} + return cands + [r for r in sorted(ms.get("rows", []), key=lambda r: r["rank"]) if r["number"] in first and r["number"] not in have] + + +def _outside_boundary_rows(ms: dict, cands: list[dict], title: str) -> list[dict]: + """候補に入らなかった行(切り出しの境界の外)を、順位の順に DROPPED の選定の行にする。""" + cand_nums = {r["number"] for r in cands} + rest = [r for r in sorted(ms.get("rows", []), key=lambda r: r["rank"]) if r["number"] not in cand_nums] + return [ + {"number": r["number"], "milestone": title, "rank": r["rank"], "decision": DROPPED, "reason": "順位(切り出しの境界の外)"} + for r in rest + ] + + def choose_issues(env: Env, rec: dict, rank: dict, lap_n: int, descriptions: dict, lap_created: set = frozenset()) -> dict: """選定(決定 2)。入力は rank の出力・記録・マイルストーンの説明だけ。戻り値は {"milestone", "selection", "candidates"}。選べたマイルストーンが無ければ milestone は None。""" @@ -154,9 +171,7 @@ def choose_issues(env: Env, rec: dict, rank: dict, lap_n: int, descriptions: dic first = first_issues(rec) for title in order: ms = by_title[title] - cands = candidates_of(ms, rec["max_issues"], pending_issues(rec)) - have = {r["number"] for r in cands} - cands += [r for r in sorted(ms.get("rows", []), key=lambda r: r["rank"]) if r["number"] in first and r["number"] not in have] + cands = _candidates_with_first(ms, rec["max_issues"], pending_issues(rec), first) total += len(cands) text = sprint_mvv.mvv_sections(descriptions.get(title) or "") unapproved = text is not None and (title, pm.sha256_text(text)) not in approvals @@ -169,12 +184,7 @@ def choose_issues(env: Env, rec: dict, rank: dict, lap_n: int, descriptions: dic ) rows_out += here if picked: - cand_nums = {r["number"] for r in cands} - rest = [r for r in sorted(ms.get("rows", []), key=lambda r: r["rank"]) if r["number"] not in cand_nums] - rows_out += [ - {"number": r["number"], "milestone": title, "rank": r["rank"], "decision": DROPPED, "reason": "順位(切り出しの境界の外)"} - for r in rest - ] + rows_out += _outside_boundary_rows(ms, cands, title) return {"milestone": title, "selection": rows_out, "candidates": total, "sprint_mvv": text} return {"milestone": None, "selection": rows_out, "candidates": total, "sprint_mvv": None} From b22620269a3b5c03342c17ca8d8087bf38267f65 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 11:18:03 +0000 Subject: [PATCH 52/70] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/scripts/lib/parallel=5Frun.py#=5FRun.take?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit take の request の枝を take_release_request、gate1/gate2/ask の枝を take_question へ抽出した。 take は入力の型確認と種別分岐だけを持つ。return・publish_questions・durable.set_event の順序と回数は変えていない。 抽出で増えた 6 行に合わせ、行数の例外リストの上限を 682 にした。 Item-Id: I-005 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_run.py | 40 +++++++++++-------- ..._scripts__lib__parallel_run.py--lines.json | 2 +- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index f398258c7..49b5b4f7a 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -288,27 +288,33 @@ def take(self, msg) -> None: self.stop = True self.force = self.force or bool(msg.get("force")) elif kind == "request": - if all(r["id"] != msg.get("id") for r in self.pending): - self.pending.append(msg) - self.received[msg.get("lane", "")] = self.received.get(msg.get("lane", ""), 0) + 1 - durable.set_event(f"lane-{msg.get('lane')}", {"received": self.received[msg.get("lane", "")]}) + self.take_release_request(msg) elif kind in ("gate1", "gate2", "ask"): - if any(q.get("id") == msg.get("id") for q in self.questions) or msg.get("id") in self.answered: - return # 同じ依頼の打ち直しは数えない(id は送る側が依頼の中身から決める) - key = cause_of(msg) - same = next((q for q in self.questions if key and q.get("cause") == key), None) - if same is not None: # 同じ原因の問いは 1 つにまとめ、答えを依頼してきた全レーンへ配る(異常系 5) - same["also"] = [*same.get("also", []), msg.get("id")] - same["merged"] = [*same.get("merged", []), {"id": msg.get("id"), "lane": msg.get("lane"), "question": msg.get("question")}] - self.publish_questions() - return - if msg.get("source") == "ndf-ask:human-action": # 代われない操作: レーンへ入る 1 行を添える(6d) - msg = {**msg, "attach": f"python3 {RELAY} attach {msg.get('lane')}"} - self.questions.append({**msg, "cause": key} if key else msg) - self.publish_questions() + self.take_question(msg) elif kind == "answer": self.reply(msg) + def take_release_request(self, msg: dict) -> None: + if all(r["id"] != msg.get("id") for r in self.pending): + self.pending.append(msg) + self.received[msg.get("lane", "")] = self.received.get(msg.get("lane", ""), 0) + 1 + durable.set_event(f"lane-{msg.get('lane')}", {"received": self.received[msg.get("lane", "")]}) + + def take_question(self, msg: dict) -> None: + if any(q.get("id") == msg.get("id") for q in self.questions) or msg.get("id") in self.answered: + return # 同じ依頼の打ち直しは数えない(id は送る側が依頼の中身から決める) + key = cause_of(msg) + same = next((q for q in self.questions if key and q.get("cause") == key), None) + if same is not None: # 同じ原因の問いは 1 つにまとめ、答えを依頼してきた全レーンへ配る(異常系 5) + same["also"] = [*same.get("also", []), msg.get("id")] + same["merged"] = [*same.get("merged", []), {"id": msg.get("id"), "lane": msg.get("lane"), "question": msg.get("question")}] + self.publish_questions() + return + if msg.get("source") == "ndf-ask:human-action": # 代われない操作: レーンへ入る 1 行を添える(6d) + msg = {**msg, "attach": f"python3 {RELAY} attach {msg.get('lane')}"} + self.questions.append({**msg, "cause": key} if key else msg) + self.publish_questions() + def publish_questions(self) -> None: durable.set_event("questions", {"seq": self.bump(), "items": self.questions}) diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json index 04301832a..17554975e 100644 --- a/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/scripts/lib/parallel_run.py", "name": "", "kind": "lines", "reason": "#2436 で並列の計画の耐久ワークフロー(耐久ステップ・_Run の周回・一時レーンの起動・外の入口)を 1 モジュールに置いて 676 行。500 行以下にしたら消す。分けるなら、外の世界の読み出し(relay・base_tracking_ref・release_plugin・git_head・dev_version)と一時レーンの組み立て(helper_spawn・present_approval・helper_task)を別のモジュールへ移す", "lines": 676} +{"path": "plugins/ndf/scripts/lib/parallel_run.py", "name": "", "kind": "lines", "reason": "#2436 で並列の計画の耐久ワークフロー(耐久ステップ・_Run の周回・一時レーンの起動・外の入口)を 1 モジュールに置いて 676 行。500 行以下にしたら消す。分けるなら、外の世界の読み出し(relay・base_tracking_ref・release_plugin・git_head・dev_version)と一時レーンの組み立て(helper_spawn・present_approval・helper_task)を別のモジュールへ移す", "lines": 682} From 695946cb1834b0a75934fc864a377341bbda2218 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 11:32:43 +0000 Subject: [PATCH 53/70] =?UTF-8?q?Revert:=20cross-refactoring=20=E3=81=AE?= =?UTF-8?q?=E6=94=B9=E5=96=84=E3=82=92=E7=9D=80=E6=89=8B=E5=89=8D=E3=81=AE?= =?UTF-8?q?=E6=9C=A8=E3=81=B8=E6=88=BB=E3=81=99=EF=BC=88=E8=90=BD=E3=81=A1?= =?UTF-8?q?=E3=81=9F=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=B5=B0=E3=82=89?= =?UTF-8?q?=E3=81=9B=E7=9B=B4=E3=81=99=E8=AA=9E=E3=81=8C=E7=84=A1=E3=81=84?= =?UTF-8?q?=EF=BC=88=E5=A4=89=E6=9B=B4=E8=B5=B7=E5=9B=A0=E3=82=92=E6=8C=99?= =?UTF-8?q?=E3=81=92=E3=82=89=E3=82=8C=E3=81=AA=E3=81=84=EF=BC=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/ndf/scripts/lib/parallel_files.py | 1 - plugins/ndf/scripts/lib/parallel_release.py | 4 +- plugins/ndf/scripts/lib/parallel_run.py | 95 ++++++++----------- plugins/ndf/scripts/lib/sprint_loop.py | 56 ++++++++++- plugins/ndf/scripts/lib/sprint_loop_gate.py | 4 +- plugins/ndf/scripts/lib/sprint_loop_gate2.py | 60 +----------- plugins/ndf/scripts/lib/sprint_loop_select.py | 32 +++---- ..._scripts__lib__parallel_run.py--lines.json | 2 +- 8 files changed, 114 insertions(+), 140 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_files.py b/plugins/ndf/scripts/lib/parallel_files.py index e0664eca4..1e0e52593 100644 --- a/plugins/ndf/scripts/lib/parallel_files.py +++ b/plugins/ndf/scripts/lib/parallel_files.py @@ -12,7 +12,6 @@ PARALLEL_DIR = Path(".ndf") / "parallel" ROLE_FILE = "role.json" # 統括役の作業ディレクトリの役割のファイル(relay_lib.hub.ROLE_FILE と同じ公開の形。統括役を import しない) -RELEASE_REQUEST_PHASE = "配布の依頼" # 配布の依頼のプランの「フェーズ」(書く parallel_release と読む sprint_loop_gate が共有する) def plans_in(main: Path) -> list[str]: diff --git a/plugins/ndf/scripts/lib/parallel_release.py b/plugins/ndf/scripts/lib/parallel_release.py index de0362b57..999598ffd 100644 --- a/plugins/ndf/scripts/lib/parallel_release.py +++ b/plugins/ndf/scripts/lib/parallel_release.py @@ -15,7 +15,6 @@ from typing import Callable, Iterable, Optional import parallel_plan as pp -from parallel_files import RELEASE_REQUEST_PHASE MAX_HELPER_SEQ = 9999 # 一時レーンの名前 `<計画名>-rel<連番>` を 32 字に収める(I1) @@ -233,6 +232,9 @@ def present_gate_steps(state: str, repo: str | None, approval: str, prs: str, mo return steps +RELEASE_REQUEST_PHASE = "配布の依頼" + + def plan_release_request(a, repo: str, plan_name: str, lane: str, prs: list[str] | None = None) -> dict: """並列の計画のレーンの本番のステージ(#2436 の F6)。本番を流さず、オーガナイザーへ配布の依頼を送る。 diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 49b5b4f7a..f98be2ff9 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -288,33 +288,27 @@ def take(self, msg) -> None: self.stop = True self.force = self.force or bool(msg.get("force")) elif kind == "request": - self.take_release_request(msg) + if all(r["id"] != msg.get("id") for r in self.pending): + self.pending.append(msg) + self.received[msg.get("lane", "")] = self.received.get(msg.get("lane", ""), 0) + 1 + durable.set_event(f"lane-{msg.get('lane')}", {"received": self.received[msg.get("lane", "")]}) elif kind in ("gate1", "gate2", "ask"): - self.take_question(msg) + if any(q.get("id") == msg.get("id") for q in self.questions) or msg.get("id") in self.answered: + return # 同じ依頼の打ち直しは数えない(id は送る側が依頼の中身から決める) + key = cause_of(msg) + same = next((q for q in self.questions if key and q.get("cause") == key), None) + if same is not None: # 同じ原因の問いは 1 つにまとめ、答えを依頼してきた全レーンへ配る(異常系 5) + same["also"] = [*same.get("also", []), msg.get("id")] + same["merged"] = [*same.get("merged", []), {"id": msg.get("id"), "lane": msg.get("lane"), "question": msg.get("question")}] + self.publish_questions() + return + if msg.get("source") == "ndf-ask:human-action": # 代われない操作: レーンへ入る 1 行を添える(6d) + msg = {**msg, "attach": f"python3 {RELAY} attach {msg.get('lane')}"} + self.questions.append({**msg, "cause": key} if key else msg) + self.publish_questions() elif kind == "answer": self.reply(msg) - def take_release_request(self, msg: dict) -> None: - if all(r["id"] != msg.get("id") for r in self.pending): - self.pending.append(msg) - self.received[msg.get("lane", "")] = self.received.get(msg.get("lane", ""), 0) + 1 - durable.set_event(f"lane-{msg.get('lane')}", {"received": self.received[msg.get("lane", "")]}) - - def take_question(self, msg: dict) -> None: - if any(q.get("id") == msg.get("id") for q in self.questions) or msg.get("id") in self.answered: - return # 同じ依頼の打ち直しは数えない(id は送る側が依頼の中身から決める) - key = cause_of(msg) - same = next((q for q in self.questions if key and q.get("cause") == key), None) - if same is not None: # 同じ原因の問いは 1 つにまとめ、答えを依頼してきた全レーンへ配る(異常系 5) - same["also"] = [*same.get("also", []), msg.get("id")] - same["merged"] = [*same.get("merged", []), {"id": msg.get("id"), "lane": msg.get("lane"), "question": msg.get("question")}] - self.publish_questions() - return - if msg.get("source") == "ndf-ask:human-action": # 代われない操作: レーンへ入る 1 行を添える(6d) - msg = {**msg, "attach": f"python3 {RELAY} attach {msg.get('lane')}"} - self.questions.append({**msg, "cause": key} if key else msg) - self.publish_questions() - def publish_questions(self) -> None: durable.set_event("questions", {"seq": self.bump(), "items": self.questions}) @@ -354,13 +348,6 @@ def answer_from_ranges(self) -> None: self.answer(r["id"], "released", rel.get("version"), rel["n"]) self.pending = [r for r in self.pending if r["id"] not in hit] - def release_pending(self) -> None: - """配布が流れていなければ、出した範囲で答えてから残りの依頼で配布を開く。""" - if self.current is None and self.pending: - self.answer_from_ranges() - if self.pending: - self.open_release() - def open_release(self) -> None: """流れている配布が無く依頼があれば、他のレーンを待たずに提示 1 を組む(I9)。""" n = len(self.releases) + 1 @@ -513,42 +500,34 @@ def parallel_workflow(main: str, name: str) -> dict: return _handle_stop(run, main) if run.stop: # 一時レーンが配布を流している間(提示のまま MVV 判定で本番へ進む場合も含む)の stop は、 # settle が届くまで待ってから終える(設計の失敗の 12 番。作りかけを残さない) - _recv_resume(run, wait) + msg = durable.recv("resume", min(wait, 30)) + if isinstance(msg, dict): + run.resume(msg) continue _spawn_ready(run, main) - run.release_pending() + if run.current is None and run.pending: + run.answer_from_ranges() + if run.pending: + run.open_release() if _run_finished(run): - done = _finish_or_flush_held(run, main, name) - if done is not None: - return done + held = step_held_loops(main, name, sorted(run.answered)) + if not held: + step_finish(main, name, run.progress()) + return run.summary("finished") + # 止まったレーンの保留の周回のまとめを一時レーンで流してから終える(異常系 11)。一時レーンは 1 本ずつ起こす + todo = [x for x in held if x not in run.held_spawned] + if todo and not set(run.held_spawned) & set(held): + run.held_spawned.append(todo[0]) + seq = len(run.releases) + len(run.held_spawned) + run.pause("held", loops=[todo[0]], spawn=helper_spawn(name, seq, 0, pending=todo[0])) run.take(durable.recv("inbox", wait if run.current is None else min(wait, 30))) if run.current is not None: - _recv_resume(run, wait) + msg = durable.recv("resume", min(wait, 30)) + if isinstance(msg, dict): + run.resume(msg) _observe_run(run, main) -def _recv_resume(run: _Run, wait: float) -> None: - """一時レーンからの再開の知らせを最大 30 秒待ち、届いたら反映する。""" - msg = durable.recv("resume", min(wait, 30)) - if isinstance(msg, dict): - run.resume(msg) - - -def _finish_or_flush_held(run: _Run, main: str, name: str) -> Optional[dict]: - """保留の周回が無ければ記録を閉じて要約を返す。あれば一時レーンを 1 本起こして None を返す。""" - held = step_held_loops(main, name, sorted(run.answered)) - if not held: - step_finish(main, name, run.progress()) - return run.summary("finished") - # 止まったレーンの保留の周回のまとめを一時レーンで流してから終える(異常系 11)。一時レーンは 1 本ずつ起こす - todo = [x for x in held if x not in run.held_spawned] - if todo and not set(run.held_spawned) & set(held): - run.held_spawned.append(todo[0]) - seq = len(run.releases) + len(run.held_spawned) - run.pause("held", loops=[todo[0]], spawn=helper_spawn(name, seq, 0, pending=todo[0])) - return None - - def _started(run: _Run) -> list[str]: return [n for n, st in run.lanes.items() if st.get("started")] diff --git a/plugins/ndf/scripts/lib/sprint_loop.py b/plugins/ndf/scripts/lib/sprint_loop.py index 8c8ac8b68..37fc609c5 100644 --- a/plugins/ndf/scripts/lib/sprint_loop.py +++ b/plugins/ndf/scripts/lib/sprint_loop.py @@ -33,6 +33,7 @@ RETURNED, QUESTION_PENDING, PENDING, + GATE_1, FINISHED, NOT_RUN, LoopError, @@ -68,6 +69,7 @@ read_done_items, continuation, CONTINUATION_STEP, + mark_issue, refresh_state, handle_gate, failure_key, @@ -77,7 +79,7 @@ owner_line, ) -from sprint_loop_gate2 import apply_answer, finish_held, gate2_answer, gate2_pipeline, pending_pipeline +from sprint_loop_gate2 import finish_held, gate2_answer, gate2_pipeline, pending_pipeline # ---------------------------------------------------------------- start @@ -377,6 +379,58 @@ def cmd_answer(env: Env, name: str, number: int, approved: bool, pr: int | None, return loop_ok(f"#{number} の答え({w['answer']})を書いた。{after}", rec, [w, *({"note": n} for n in notes)], status="answered") +def apply_answer(env: Env, rec: dict, w: dict, answer: str, pr: int | None, note: str | None = None) -> list[str]: + """人の判断待ちの行へ答えを書き、スプリント状態の承認の記録と課題の本文の節を更新する。書けなかった理由を返す。 + 直して出し直す(revise)は答えの確定でないため承認の記録を書かず、指摘と回数を行と本文に書く(#2436 の 6e)。""" + w.update(answer=answer, answered_at=env.now()) + if pr: + w["pr"] = pr + notes: list[str] = [] + if answer == "revise": + w["note"] = note or "" + w["revise_count"] = sum(1 for x in rec.get("waiting", []) if x["number"] == w["number"] and x.get("answer") == "revise") + if err := mark_issue(env, rec, w): + notes.append(err) + return notes + lap = rec["laps"][w["lap"] - 1] + state = lap.get("state_path") + if state: + cmd = [ + sys.executable, + str(SPRINT_STATE), + "gate", + state, + GATE_1, + "--by", + "user", + "--what", + f"開発ループ {rec['name']} の #{w['number']} の設計", + ] + cmd += ["--outcome", w["answer"], "--root", str(env.main_dir())] + (["--pr", str(w["pr"])] if w.get("pr") else []) + code, so, se = env.run(cmd, cwd=str(env.main_dir())) + if code != 0: + notes.append(f"承認の記録を書けない({code}): {(se or so).strip()[:200]}") + if err := mark_issue(env, rec, w): + notes.append(err) + return notes + + +def apply_gate1_answers(env: Env, rec: dict, answers: dict) -> None: + """オーガナイザーへ送った設計の承認の答えを、持ち主のレーンが周回の境で処理する(#2436 の 6e・6f)。 + 承認ならラベル design-approved を付けて承認を書き、直して出し直すなら指摘を、やらないなら差し戻しを書く。""" + for w in [w for w in rec.get("waiting", []) if w.get("request") and not w.get("answer")]: + a = (answers.get(w["request"]) or {}).get("answer") + if not isinstance(a, dict): + continue + result = a.get("result") + if result == "approved" and w.get("pr"): + code, so, se = env.run(["gh", "pr", "edit", str(w["pr"]), "--add-label", "design-approved"], cwd=str(env.main_dir())) + if code != 0: + w["label_error"] = (se or so).strip()[:200] + if result in ("approved", "rejected", "revise"): + apply_answer(env, rec, w, result, w.get("pr"), a.get("note")) + + def cmd_approve_mvv(env: Env, name: str, milestone: str) -> dict: with open_record(env, name) as (rec, before): try: diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index a26255fa7..38a4d1ef1 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -15,7 +15,6 @@ import jsonio import project_mvv as pm import step_result -from parallel_files import RELEASE_REQUEST_PHASE from sprint_loop_record import ( TOOL, @@ -297,6 +296,9 @@ def handle_gate2(env: Env, rec: dict, before: dict, lap: dict, plan: str | None return stop_and_report(env, rec, before, stop, "承認ゲート 2 を人へ戻した。本番を出さずに止まる(利用者の承認を求める)", {"gate": 2}) +RELEASE_REQUEST_PHASE = "配布の依頼" # parallel_release.RELEASE_REQUEST_PHASE(配布の依頼のプランの「フェーズ」) + + def request_record(plan: str) -> tuple[Path, dict]: """配布の依頼のプランの状態ディレクトリの `work/request.txt`(`parallel.py request` が書く)のパスと中身。""" out = Path(plan).with_name(Path(plan).stem + "-state") / "work" / "request.txt" diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate2.py b/plugins/ndf/scripts/lib/sprint_loop_gate2.py index b3ed94e1c..42acf2599 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate2.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate2.py @@ -6,7 +6,6 @@ from __future__ import annotations import shlex -import sys from pathlib import Path import clock @@ -14,7 +13,6 @@ import jsonio from sprint_loop_record import ( - GATE_1, GATE_2, SPRINT_STATE, SUPERVISE, @@ -33,7 +31,6 @@ finish_lap, gate2_resume_step, loop_ok, - mark_issue, plan_info, queue_command, stages_after, @@ -190,62 +187,11 @@ def settle_release(env: Env, rec: dict, lap: dict, a: dict) -> None: lap["failure"] = {"issues": lap.get("issues_kept") or selected_issues(lap), "kind": "配布", "step": "配布の失敗"} -def apply_answer(env: Env, rec: dict, w: dict, answer: str, pr: int | None, note: str | None = None) -> list[str]: - """人の判断待ちの行へ答えを書き、スプリント状態の承認の記録と課題の本文の節を更新する。書けなかった理由を返す。 - 直して出し直す(revise)は答えの確定でないため承認の記録を書かず、指摘と回数を行と本文に書く(#2436 の 6e)。""" - w.update(answer=answer, answered_at=env.now()) - if pr: - w["pr"] = pr - notes: list[str] = [] - if answer == "revise": - w["note"] = note or "" - w["revise_count"] = sum(1 for x in rec.get("waiting", []) if x["number"] == w["number"] and x.get("answer") == "revise") - if err := mark_issue(env, rec, w): - notes.append(err) - return notes - lap = rec["laps"][w["lap"] - 1] - state = lap.get("state_path") - if state: - cmd = [ - sys.executable, - str(SPRINT_STATE), - "gate", - state, - GATE_1, - "--by", - "user", - "--what", - f"開発ループ {rec['name']} の #{w['number']} の設計", - ] - cmd += ["--outcome", w["answer"], "--root", str(env.main_dir())] + (["--pr", str(w["pr"])] if w.get("pr") else []) - code, so, se = env.run(cmd, cwd=str(env.main_dir())) - if code != 0: - notes.append(f"承認の記録を書けない({code}): {(se or so).strip()[:200]}") - if err := mark_issue(env, rec, w): - notes.append(err) - return notes - - -def apply_gate1_answers(env: Env, rec: dict, answers: dict) -> None: - """オーガナイザーへ送った設計の承認の答えを、持ち主のレーンが周回の境で処理する(#2436 の 6e・6f)。 - 承認ならラベル design-approved を付けて承認を書き、直して出し直すなら指摘を、やらないなら差し戻しを書く。""" - for w in [w for w in rec.get("waiting", []) if w.get("request") and not w.get("answer")]: - a = (answers.get(w["request"]) or {}).get("answer") - if not isinstance(a, dict): - continue - result = a.get("result") - if result == "approved" and w.get("pr"): - code, so, se = env.run(["gh", "pr", "edit", str(w["pr"]), "--add-label", "design-approved"], cwd=str(env.main_dir())) - if code != 0: - w["label_error"] = (se or so).strip()[:200] - if result in ("approved", "rejected", "revise"): - apply_answer(env, rec, w, result, w.get("pr"), a.get("note")) - - -def pending_step(env: Env, rec: dict, before: dict) -> dict | None: +def pending_step(env: Env, rec: dict, before: dict, apply_gate1=None) -> dict | None: """周回の境で、答えの届いた依頼を古い順に扱う(境の 2〜6)。まとめを流す周回があればその結果を返し、無ければ None。""" answers = read_answers(env, rec) - apply_gate1_answers(env, rec, answers) + if apply_gate1 is not None: + apply_gate1(env, rec, answers) for lap in pending_laps(rec): a = answer_of(answers, (lap.get("release") or {}).get("request")) if lap["outcome"] == RELEASE_PENDING else None if a and a.get("result") != "released": diff --git a/plugins/ndf/scripts/lib/sprint_loop_select.py b/plugins/ndf/scripts/lib/sprint_loop_select.py index 2d43f5835..889a0baba 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_select.py +++ b/plugins/ndf/scripts/lib/sprint_loop_select.py @@ -143,23 +143,6 @@ def _select_candidate_rows( return picked, here -def _candidates_with_first(ms: dict, max_issues: int, held: set[int], first: set[int]) -> list[dict]: - """マイルストーンの候補(candidates_of)に、候補に無い先頭に選ぶ課題(first)を順位の順に後ろへ足す。""" - cands = candidates_of(ms, max_issues, held) - have = {r["number"] for r in cands} - return cands + [r for r in sorted(ms.get("rows", []), key=lambda r: r["rank"]) if r["number"] in first and r["number"] not in have] - - -def _outside_boundary_rows(ms: dict, cands: list[dict], title: str) -> list[dict]: - """候補に入らなかった行(切り出しの境界の外)を、順位の順に DROPPED の選定の行にする。""" - cand_nums = {r["number"] for r in cands} - rest = [r for r in sorted(ms.get("rows", []), key=lambda r: r["rank"]) if r["number"] not in cand_nums] - return [ - {"number": r["number"], "milestone": title, "rank": r["rank"], "decision": DROPPED, "reason": "順位(切り出しの境界の外)"} - for r in rest - ] - - def choose_issues(env: Env, rec: dict, rank: dict, lap_n: int, descriptions: dict, lap_created: set = frozenset()) -> dict: """選定(決定 2)。入力は rank の出力・記録・マイルストーンの説明だけ。戻り値は {"milestone", "selection", "candidates"}。選べたマイルストーンが無ければ milestone は None。""" @@ -171,7 +154,9 @@ def choose_issues(env: Env, rec: dict, rank: dict, lap_n: int, descriptions: dic first = first_issues(rec) for title in order: ms = by_title[title] - cands = _candidates_with_first(ms, rec["max_issues"], pending_issues(rec), first) + cands = candidates_of(ms, rec["max_issues"], pending_issues(rec)) + have = {r["number"] for r in cands} + cands += [r for r in sorted(ms.get("rows", []), key=lambda r: r["rank"]) if r["number"] in first and r["number"] not in have] total += len(cands) text = sprint_mvv.mvv_sections(descriptions.get(title) or "") unapproved = text is not None and (title, pm.sha256_text(text)) not in approvals @@ -184,7 +169,12 @@ def choose_issues(env: Env, rec: dict, rank: dict, lap_n: int, descriptions: dic ) rows_out += here if picked: - rows_out += _outside_boundary_rows(ms, cands, title) + cand_nums = {r["number"] for r in cands} + rest = [r for r in sorted(ms.get("rows", []), key=lambda r: r["rank"]) if r["number"] not in cand_nums] + rows_out += [ + {"number": r["number"], "milestone": title, "rank": r["rank"], "decision": DROPPED, "reason": "順位(切り出しの境界の外)"} + for r in rest + ] return {"milestone": title, "selection": rows_out, "candidates": total, "sprint_mvv": text} return {"milestone": None, "selection": rows_out, "candidates": total, "sprint_mvv": None} @@ -204,7 +194,9 @@ def boundary_then_select(env: Env, rec: dict, before: dict) -> dict: stop = boundary_stop(env, rec) if stop and stop["condition"] in IMMEDIATE_STOPS: return stop_and_report(env, rec, before, stop, f"止まる条件に当たった({stop['condition']})") - got = pending_step(env, rec, before) + import sprint_loop + + got = pending_step(env, rec, before, sprint_loop.apply_gate1_answers) if got is not None: return got if len(pending_laps(rec)) >= rec.get("release_pending_max", RELEASE_PENDING_MAX): diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json index 17554975e..3c75e0f22 100644 --- a/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/scripts/lib/parallel_run.py", "name": "", "kind": "lines", "reason": "#2436 で並列の計画の耐久ワークフロー(耐久ステップ・_Run の周回・一時レーンの起動・外の入口)を 1 モジュールに置いて 676 行。500 行以下にしたら消す。分けるなら、外の世界の読み出し(relay・base_tracking_ref・release_plugin・git_head・dev_version)と一時レーンの組み立て(helper_spawn・present_approval・helper_task)を別のモジュールへ移す", "lines": 682} +{"path": "plugins/ndf/scripts/lib/parallel_run.py", "name": "", "kind": "lines", "reason": "#2436 で並列の計画の耐久ワークフロー(耐久ステップ・_Run の周回・一時レーンの起動・外の入口)を 1 モジュールに置いて 661 行。500 行以下にしたら消す。分けるなら、外の世界の読み出し(relay・base_tracking_ref・release_plugin・git_head・dev_version)と一時レーンの組み立て(helper_spawn・present_approval・helper_task)を別のモジュールへ移す", "lines": 661} From 690952e3495c2845a178854f8e96a6046717d71c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 11:49:55 +0000 Subject: [PATCH 54/70] =?UTF-8?q?Fix:=20bash=20=E3=81=8C=E6=A4=9C=E5=87=BA?= =?UTF-8?q?=E3=81=AE=E3=81=97=E3=81=8D=E3=81=84=E5=80=A4=EF=BC=885%?= =?UTF-8?q?=EF=BC=89=E3=82=92=E5=89=B2=E3=81=A3=E3=81=9F=E3=81=AE=E3=81=A7?= =?UTF-8?q?=20project.yml=20=E3=81=AE=E8=A8=80=E8=AA=9E=E3=81=8B=E3=82=89?= =?UTF-8?q?=E5=A4=96=E3=81=99=EF=BC=88#2436=20=E3=81=AE=E3=82=B9=E3=83=97?= =?UTF-8?q?=E3=83=AA=E3=83=B3=E3=83=88=E3=81=AE=20CI=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit develop で TypeScript が増え、スプリントで Python が増えて、bash は 63 / 1,263 ファイルになった。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KYHP819RPG1z3k6oVRWvoD --- .serena/project.yml | 1 - plugins/mcp/mcp-serena/tests/test_serena_lsp_this_repo.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.serena/project.yml b/.serena/project.yml index 84d4ee6f9..4fb3db77a 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -180,7 +180,6 @@ activation_command_timeout: 180.0 language_servers: - python - typescript -- bash # list of APIs (facades or facade methods, e.g. "lsp" or "lsp.get_diagnostics_for_symbol") to include in the REPL # that would otherwise be disabled (particularly optional methods, which are disabled by default). diff --git a/plugins/mcp/mcp-serena/tests/test_serena_lsp_this_repo.py b/plugins/mcp/mcp-serena/tests/test_serena_lsp_this_repo.py index 7abc0bfa9..3b4c92704 100644 --- a/plugins/mcp/mcp-serena/tests/test_serena_lsp_this_repo.py +++ b/plugins/mcp/mcp-serena/tests/test_serena_lsp_this_repo.py @@ -11,6 +11,6 @@ def test_this_repository_project_yml_matches_detection(): assert code == 0 detected = [d["language"] for d in out["detected"]] text = (REPO / ".serena/project.yml").read_text() - assert py.read_list(text, "language_servers") == detected == ["python", "typescript", "bash"] + assert py.read_list(text, "language_servers") == detected == ["python", "typescript"] assert {".serena/**", ".worktrees/**"} <= set(py.read_list(text, "ignored_paths")) assert py.read_list(text, "mcp_serena_excluded") == [] From b0367da2a3f2dfba4250f64db1383ea11e44ed49 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 12:22:32 +0000 Subject: [PATCH 55/70] =?UTF-8?q?Fix:=20=E6=9C=AC=E7=95=AA=E3=81=AE?= =?UTF-8?q?=E7=85=A7=E5=90=88=E3=81=AE=E5=89=8D=E3=81=AB=E6=B6=88=E3=81=88?= =?UTF-8?q?=E3=81=9F=20release/v<=E7=89=88>=20=E3=82=92=20push=20=E3=81=97?= =?UTF-8?q?=E7=9B=B4=E3=81=97=E3=80=81=E9=85=8D=E5=B8=83=E3=81=AE=E7=AD=94?= =?UTF-8?q?=E3=81=88=E3=81=A7=E6=AD=A2=E3=81=BE=E3=81=A3=E3=81=9F=E3=83=AC?= =?UTF-8?q?=E3=83=BC=E3=83=B3=E3=82=92=E8=B5=B7=E3=81=93=E3=81=99=201=20?= =?UTF-8?q?=E8=A1=8C=E3=82=92=E8=BF=94=E3=81=99=EF=BC=88#2436=20=E3=81=AE?= =?UTF-8?q?=20cross-review=20round=205=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - release-steps.py: 開発版の PR のマージで delete_branch_on_merge が origin の release/v<版> を消しても、照合と本番の PR の作成の前にローカルの先端を push し直す - parallel_run.py: settle で答えを立てた宛先のうち止まっているレーンへ relay.py restart の 1 行を notice の wake に載せる(設計の異常系 6)。全レーンが止まっていれば異常系 11 の保留の周回に任せる - parallel.md: オーガナイザーが notice の wake を打つ Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_run.py | 10 +++++++--- plugins/ndf/scripts/release-steps.py | 8 ++++++++ plugins/ndf/scripts/tests/test_parallel_run.py | 17 +++++++++++++++++ .../tests/test_release_approved_commit.py | 14 +++++++++++++- .../development-workflow/references/parallel.md | 2 +- ...f__scripts__lib__parallel_run.py--lines.json | 2 +- 6 files changed, 47 insertions(+), 6 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index f98be2ff9..990d63b7b 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -416,6 +416,7 @@ def settle(self, rel: dict, msg: dict) -> None: got = step_presented(rel["dirs"][-1]) rel["prs"] = got.get("prs") or [] hit = set(pr.answerable(self.pending, rel["prs"])) + answered = [r for r in self.pending if r["id"] in hit] if result == "released": rel.update(state="released", version=msg.get("version") or rel.get("version")) durable.set_event( @@ -424,19 +425,22 @@ def settle(self, rel: dict, msg: dict) -> None: "sha": rel.get("sha"), "version": rel["version"], "prs": rel["prs"], - "lanes": sorted({r["lane"] for r in self.pending if r["id"] in hit}), + "lanes": sorted({r["lane"] for r in answered}), }, ) - for r in [r for r in self.pending if r["id"] in hit]: + for r in answered: self.answer(r["id"], "released", rel["version"], rel["n"]) else: rel.update(state="rejected" if result == "rejected" else "failed", reason=msg.get("reason")) - for r in [r for r in self.pending if r["id"] in hit]: + for r in answered: self.answer(r["id"], rel["state"], None, rel["n"], msg.get("reason")) self.pending = [r for r in self.pending if r["id"] not in hit] for r in self.pending: durable.set_event(f"defer-{r['id']}", {"seq": rel["n"], "reason": "範囲に入らないため次の配布へ回した", "at": None}) self.current = None + stopped = self.stopped() # 止まった宛先を起こす 1 行を知らせへ(異常系 6)。全レーンが止まれば異常系 11 に任せる + if (wake := sorted({r["lane"] for r in answered if r.get("lane") in stopped})) and len(stopped) < len(self.order): + self.pause("notice", lanes=[], attach=[], wake=[f"python3 {RELAY} restart {n}" for n in wake]) def helper_spawn(name: str, n: int, k: int, pending: Optional[str] = None) -> str: diff --git a/plugins/ndf/scripts/release-steps.py b/plugins/ndf/scripts/release-steps.py index 0795da650..e138dfdde 100755 --- a/plugins/ndf/scripts/release-steps.py +++ b/plugins/ndf/scripts/release-steps.py @@ -615,6 +615,13 @@ def _merge_prod_pr(root, a, plugin, branch, prod, release_pr, approved, allowed, return main_pr, merge, verdict +def _restore_branch(root, branch): + """開発版の PR のマージで origin の release/v<版> が消えていれば(delete_branch_on_merge)、ローカルの先端を push し直す。 + 本番の照合(check_approved の fetch)と本番の PR の head はこの枝を読む。在れば何もしない(強制 push はしない)。""" + if git(root, "ls-remote", "--exit-code", "--heads", "origin", f"refs/heads/{branch}", check=False).returncode != 0: + git(root, "push", "-q", "origin", f"HEAD:refs/heads/{branch}") + + def cmd_release(a): root = git_root(a.root) ver = a.version @@ -668,6 +675,7 @@ def cmd_release(a): # 承認したコミットの後に、版上げの外のコミットが release/v<版> に無いか(#815・#2436 の R2)。 # 照合に渡すのは元の release/v<版> の PR で、-sync の PR は渡さない(承認の後のベースブランチの変更を含むため) allowed = approval.release_pr_allowed(root, release_pr, pr_view) + _restore_branch(root, branch) verdict = approval.check_approved(root, approved, branch, allowed) if not verdict.ok: emit(approval.gate(root, a, plugin, branch, verdict, items, metrics), EXIT_GATE) diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index 8f14dc199..59d8c5db6 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -528,6 +528,23 @@ def test_answered_pending_laps_of_stopped_loop_lanes_are_closed_by_a_helper_befo assert durable.status(pl.workflow_id("p1")) in pl.RUNNING # 流し終えるまで finished にしない +def test_settle_wakes_a_stopped_lane_of_the_range(env, tmp_path): + main, log, states = env["main"], env["log"], env["states"] + states.write_text(json.dumps({"la": "running"})) + start(main) + rid = pl.send_release_request(main, "p1", "la", [10], [1], tmp_path / "st") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release") + states.write_text(json.dumps({"la": "stopped", "lb": "running"})) # la は待ちだけで止まった(異常系 6) + until(lambda: "lb" in spawned(log)) + (prl.release_dir(main, "p1", 1, 1) / "presented.json").write_text(json.dumps({"sha": "sha1", "prs": [10]})) + resume(main, {"n": 1, "phase": "confirm"}, "c1") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "confirmed") + resume(main, {"n": 1, "phase": "settle", "result": "released", "version": "1.2.3"}, "s1") + until(lambda: pl.read_answer(main, "p1", rid)[0]) + ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("wake") and e) + assert ev["action"] == "notice" and ev["wake"] == [f"python3 {prun.RELAY} restart la"] + + def test_the_plan_does_not_finish_while_a_question_is_unanswered(env, tmp_path): main, states = env["main"], env["states"] states.write_text(json.dumps({"la": "running"})) diff --git a/plugins/ndf/scripts/tests/test_release_approved_commit.py b/plugins/ndf/scripts/tests/test_release_approved_commit.py index 792b96098..c8f964d61 100644 --- a/plugins/ndf/scripts/tests/test_release_approved_commit.py +++ b/plugins/ndf/scripts/tests/test_release_approved_commit.py @@ -86,7 +86,9 @@ def cut_release(w): SYNC = f"release/v{VER}-sync" -def run_release(monkeypatch, w, *, outside_after=None, move_head=None, dev_bump=None, prod_merged=False, published=(), **extra): +def run_release( + monkeypatch, w, *, outside_after=None, move_head=None, dev_bump=None, prod_merged=False, published=(), delete_on_merge=False, **extra +): """release --channel prod を流し、(結果, 終了コード, マージした PR と expect, gh の呼び出し) を返す。 outside_after は配布の PR の後に起点のブランチへ、move_head は本番チャネルへのマージを待つ間に(1 回だけ) release/v<版> へ別の人が入れるファイルのパス。dev_bump は承認の後に起点のブランチへ入る version.txt の中身 @@ -133,6 +135,8 @@ def wait_and_merge(r, n, expect=None, ci_wait=3600.0): if outside_after: commit(other, outside_after, "x\n", "statusline (#806)") git(other, "push", "-q", "origin", "HEAD:trunk") + if delete_on_merge: # delete_branch_on_merge: GitHub がマージした head のブランチを消す + git(w["origin"], "update-ref", "-d", f"refs/heads/{src.removeprefix('origin/')}") return state["merge"] if move_head and not state.get("moved"): state["moved"] = True @@ -202,6 +206,14 @@ def test_release_after_only_the_release_pr_is_ok(monkeypatch, world): assert any(a[:2] == ["release", "create"] for a in ghcalls) +def test_release_restores_the_release_branch_deleted_by_the_merge(monkeypatch, world): + """開発版の PR のマージで origin の release/v<版> が消えても(delete_branch_on_merge)、push し直して本番へ出す。""" + out, code, merged, _, created = run_release(monkeypatch, world, approved_sha=world["approved"], delete_on_merge=True) + tip = git(world["origin"], "rev-parse", f"release/v{VER}") + assert (out["status"], code) == ("ok", None) + assert merged == [(1, None), (2, tip)] and created[-1] == ("live", f"release/v{VER}") + + def test_release_after_the_base_moved_ships_only_the_approved_commit(monkeypatch, world): """#2436 の R2: 承認の後に起点のブランチが進んでも止まらず、進んだ分は本番チャネルへ入れない(次の配布)。""" out, code, merged, _, _ = run_release(monkeypatch, world, outside_after=SHIPPED_FILE, approved_sha=world["approved"]) diff --git a/plugins/ndf/skills/development-workflow/references/parallel.md b/plugins/ndf/skills/development-workflow/references/parallel.md index ea66ae824..1b79e8f5a 100644 --- a/plugins/ndf/skills/development-workflow/references/parallel.md +++ b/plugins/ndf/skills/development-workflow/references/parallel.md @@ -21,7 +21,7 @@ | `wait` が返したもの | すること | | --- | --- | -| `notice` | 承認待ちのレーンの `metrics.attach` の 1 行を出す。`metrics.release` があり、その `seq` の起動をまだ打っていなければ、`metrics.release.spawn` の 1 行も打つ(知らせが重なって `release` / `confirmed` を読み逃しても起動を失わない) | +| `notice` | 承認待ちのレーンの `metrics.attach` の 1 行を出す。`metrics.release` があり、その `seq` の起動をまだ打っていなければ、`metrics.release.spawn` の 1 行も打つ(知らせが重なって `release` / `confirmed` を読み逃しても起動を失わない)。`metrics.wake` があれば、その各行(配布の答えを受けた止まっているレーンを起こす `relay.py restart <レーン>`)を打つ | | `questions` | 届いている依頼(`items`)を下の「問い方」で問い、依頼ごとに答えを送る | | `release` / `confirmed` | `metrics.spawn` の 1 行(一時レーンの起動)を打つ。一時レーンが配布のプランを流し、結果を返す | | `held` | `metrics.spawn` の 1 行を打つ。一時レーンが、止まったループのレーンの答えを受けた保留の周回の残りを流す(終える前の後片付け) | diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json index 3c75e0f22..1853ee573 100644 --- a/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/scripts/lib/parallel_run.py", "name": "", "kind": "lines", "reason": "#2436 で並列の計画の耐久ワークフロー(耐久ステップ・_Run の周回・一時レーンの起動・外の入口)を 1 モジュールに置いて 661 行。500 行以下にしたら消す。分けるなら、外の世界の読み出し(relay・base_tracking_ref・release_plugin・git_head・dev_version)と一時レーンの組み立て(helper_spawn・present_approval・helper_task)を別のモジュールへ移す", "lines": 661} +{"path": "plugins/ndf/scripts/lib/parallel_run.py", "name": "", "kind": "lines", "reason": "#2436 で並列の計画の耐久ワークフロー(耐久ステップ・_Run の周回・一時レーンの起動・外の入口)を 1 モジュールに置いて 665 行。500 行以下にしたら消す。分けるなら、外の世界の読み出し(relay・base_tracking_ref・release_plugin・git_head・dev_version)と一時レーンの組み立て(helper_spawn・present_approval・helper_task)を別のモジュールへ移す", "lines": 665} From 4207e751cb2dee3c5d6f1c447e7bf32f8b7ffae6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 12:39:27 +0000 Subject: [PATCH 56/70] =?UTF-8?q?Fix:=20=E8=B5=B7=E3=81=93=E3=81=99=201=20?= =?UTF-8?q?=E8=A1=8C=E3=82=92=E4=B8=8A=E6=9B=B8=E3=81=8D=E3=81=A7=E5=A4=B1?= =?UTF-8?q?=E3=82=8F=E3=81=AA=E3=81=84=E3=83=BB=E5=81=9C=E6=AD=A2=E5=BE=85?= =?UTF-8?q?=E3=81=A1=E3=81=A7=E3=82=82=20inbox=20=E3=82=92=E8=AA=AD?= =?UTF-8?q?=E3=82=80=E3=83=BB=E8=A8=AD=E8=A8=88=E3=81=AE=E6=89=BF=E8=AA=8D?= =?UTF-8?q?=E3=81=AE=E5=89=8D=E3=81=AB=E5=85=88=E7=AB=AF=E3=82=92=E7=85=A7?= =?UTF-8?q?=E5=90=88=E3=83=BB=E5=B7=AE=E3=81=97=E6=88=BB=E3=81=97=E3=82=92?= =?UTF-8?q?=20loop=20pending=20=E3=81=A7=E7=B7=A0=E3=82=81=E3=82=8B?= =?UTF-8?q?=E3=83=BB=E8=87=AA=E5=8B=95=E3=81=A7=E8=A7=A3=E3=81=8F=E7=89=88?= =?UTF-8?q?=E6=95=B0=E3=82=92=E9=99=90=E3=82=8B=EF=BC=88#2436=20=E3=81=AE?= =?UTF-8?q?=20cross-review=20round=206=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parallel_run: settle の wake を _Run に保持し、レーンが動き出すまで後の pause すべてに添える - parallel_run: settle を待って停止を保留する間も inbox を読み、後から届いた --force と問いを受ける - sprint_loop: 設計の承認を当てる前に依頼時の stamp と設計 PR の headRefOid を比べ、動いていれば問い直す - sprint_loop_gate2: loop pending でも差し戻し・失敗の答えで保留の周回を締め直し、記録を保存する - version_merge: 解くのは release/v<版> が変えたパスの、release/v<版> が書いた版数の違いだけにする Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_run.py | 30 +++++++------ plugins/ndf/scripts/lib/sprint_loop.py | 3 ++ plugins/ndf/scripts/lib/sprint_loop_gate.py | 28 +++++++++--- plugins/ndf/scripts/lib/sprint_loop_gate2.py | 21 ++++++--- .../ndf/scripts/release_lib/version_merge.py | 43 ++++++++++++++----- .../ndf/scripts/tests/test_parallel_run.py | 23 ++++++++++ .../tests/test_release_version_merge.py | 21 ++++++++- plugins/ndf/scripts/tests/test_sprint_loop.py | 40 +++++++++++++++++ .../references/parallel.md | 6 ++- ..._scripts__lib__parallel_run.py--lines.json | 2 +- ...__scripts__lib__sprint_loop.py--lines.json | 2 +- ...ipts__lib__sprint_loop_gate.py--lines.json | 2 +- 12 files changed, 181 insertions(+), 40 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 990d63b7b..68d4b7fe1 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -254,6 +254,7 @@ def __init__(self, main: str, name: str, lanes: list[dict]): self.stall_until: dict[str, float] = {} self.helper_asked: Optional[int] = None self.held_spawned: list[str] = [] + self.wake: list[str] = [] def bump(self) -> int: self.seq += 1 @@ -261,13 +262,17 @@ def bump(self) -> int: def pause(self, action: str, **fields) -> None: """イベント `pause` は上書きされるため、流れている配布の最後の起動の指示(release / confirmed)を - 他の知らせにも `release` として添え、知らせが重なっても起動の指示を失わない。""" + 他の知らせにも `release` として添え、知らせが重なっても起動の指示を失わない。配布の答えを受けた止まっている + レーンを起こす行(`wake`)も、そのレーンが動き出すまでどの知らせにも添える。""" seq = self.bump() if action in ("release", "confirmed"): self.launch = {"seq": seq, "action": action, **fields} elif self.current is not None and self.launch: fields = {**fields, "release": self.launch} - durable.set_event("pause", {"seq": seq, "action": action, **fields}) + stopped = self.stopped() + self.wake = [n for n in self.wake if n in stopped] + wake = {"wake": [f"python3 {RELAY} restart {n}" for n in self.wake]} if self.wake else {} + durable.set_event("pause", {"seq": seq, "action": action, **fields, **wake}) def progress(self) -> dict: return {"lanes": self.lanes, "releases": [{k: v for k, v in r.items() if k != "presented"} for r in self.releases]} @@ -440,7 +445,8 @@ def settle(self, rel: dict, msg: dict) -> None: self.current = None stopped = self.stopped() # 止まった宛先を起こす 1 行を知らせへ(異常系 6)。全レーンが止まれば異常系 11 に任せる if (wake := sorted({r["lane"] for r in answered if r.get("lane") in stopped})) and len(stopped) < len(self.order): - self.pause("notice", lanes=[], attach=[], wake=[f"python3 {RELAY} restart {n}" for n in wake]) + self.wake = sorted({*self.wake, *wake}) + self.pause("notice", lanes=[], attach=[]) def helper_spawn(name: str, n: int, k: int, pending: Optional[str] = None) -> str: @@ -502,18 +508,15 @@ def parallel_workflow(main: str, name: str) -> dict: while True: if run.stop and (run.current is None or run.force): # --force は配布の settle を待たず、一時レーンも止める return _handle_stop(run, main) - if run.stop: # 一時レーンが配布を流している間(提示のまま MVV 判定で本番へ進む場合も含む)の stop は、 - # settle が届くまで待ってから終える(設計の失敗の 12 番。作りかけを残さない) - msg = durable.recv("resume", min(wait, 30)) - if isinstance(msg, dict): - run.resume(msg) - continue - _spawn_ready(run, main) - if run.current is None and run.pending: + # 一時レーンが配布を流している間(提示のまま MVV 判定で本番へ進む場合も含む)の stop は、settle が届くまで + # 待ってから終える(設計の失敗の 12 番。作りかけを残さない)。待つ間も inbox を読み、--force と問いを受ける + if not run.stop: + _spawn_ready(run, main) + if not run.stop and run.current is None and run.pending: run.answer_from_ranges() if run.pending: run.open_release() - if _run_finished(run): + if not run.stop and _run_finished(run): held = step_held_loops(main, name, sorted(run.answered)) if not held: step_finish(main, name, run.progress()) @@ -529,7 +532,8 @@ def parallel_workflow(main: str, name: str) -> dict: msg = durable.recv("resume", min(wait, 30)) if isinstance(msg, dict): run.resume(msg) - _observe_run(run, main) + if not run.stop: + _observe_run(run, main) def _started(run: _Run) -> list[str]: diff --git a/plugins/ndf/scripts/lib/sprint_loop.py b/plugins/ndf/scripts/lib/sprint_loop.py index 37fc609c5..26af0b194 100644 --- a/plugins/ndf/scripts/lib/sprint_loop.py +++ b/plugins/ndf/scripts/lib/sprint_loop.py @@ -15,6 +15,7 @@ import jsonio import project_mvv as pm import repo as repo_lib +import sprint_loop_gate import sprint_mvv import step_result from pace import PaceError, read_loop, read_pace @@ -424,6 +425,8 @@ def apply_gate1_answers(env: Env, rec: dict, answers: dict) -> None: continue result = a.get("result") if result == "approved" and w.get("pr"): + if sprint_loop_gate.regate1(env, w): # 問いの後に設計 PR が動いた: 新しい先端で問い直す + continue code, so, se = env.run(["gh", "pr", "edit", str(w["pr"]), "--add-label", "design-approved"], cwd=str(env.main_dir())) if code != 0: w["label_error"] = (se or so).strip()[:200] diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index 38a4d1ef1..beaf8ea14 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -369,23 +369,41 @@ def move_to_waiting( def lane_gate1(env: Env, w: dict, report: str, state: str) -> dict | None: - """並列の計画のレーンなら、設計の承認の依頼(`kind: gate1`)をオーガナイザーへ送り、{request, plan} を返す。 - レーンでない・送れなければ None(今どおり人の判断待ちとして残す)。印は設計 PR の先頭の SHA(問いの時点の印)。""" + """並列の計画のレーンなら、設計の承認の依頼(`kind: gate1`)をオーガナイザーへ送り、{request, plan, stamp, report, state} + を返す。レーンでない・送れなければ None(今どおり人の判断待ちとして残す)。印は設計 PR の先頭の SHA(問いの時点の印)。""" try: import parallel_lane got = parallel_lane.lane_of(env.main_dir()) if not got: return None - code, so, _ = env.run(["gh", "pr", "view", str(w["pr"]), "--json", "headRefOid", "-q", ".headRefOid"], cwd=str(env.main_dir())) - stamp = so.strip() if code == 0 else "" + stamp = design_head(env, w) rid = parallel_lane.request_gate1( env.main_dir(), got[0], got[1].name, w["number"], int(w["pr"]), report, state, stamp or "0" * 12, verdict=w.get("verdict"), reasons=[w.get("reason")] if w.get("reason") else [], ) # fmt: skip except Exception: # noqa: BLE001 — 送れなければ今どおり人の判断待ち(loop answer で答える) return None - return {"request": rid, "plan": got[0]} + return {"request": rid, "plan": got[0], "stamp": stamp, "report": report, "state": state} + + +def design_head(env: Env, w: dict) -> str: + code, so, _ = env.run(["gh", "pr", "view", str(w["pr"]), "--json", "headRefOid", "-q", ".headRefOid"], cwd=str(env.main_dir())) + return so.strip() if code == 0 else "" + + +def regate1(env: Env, w: dict) -> bool: + """承認の答えを当てる前に、問いの時点の設計 PR の先端(stamp)と今の先端を比べる。動いていれば新しい先端で問い直し、 + 読めなければ次の境で読み直す(どちらも True。古い範囲への答えで承認を書かない)。印の無い古い依頼は比べない。""" + stamp = w.get("stamp") or "" + if not stamp.strip("0"): + return False + head = design_head(env, w) + if head == stamp: + return False + if head and (sent := lane_gate1(env, w, w.get("report") or "", w.get("state") or "")): + w.update(sent, restamped=stamp) + return True def drop_issues(plan: str, moved: list[int]) -> None: diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate2.py b/plugins/ndf/scripts/lib/sprint_loop_gate2.py index 42acf2599..8123648f7 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate2.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate2.py @@ -187,15 +187,23 @@ def settle_release(env: Env, rec: dict, lap: dict, a: dict) -> None: lap["failure"] = {"issues": lap.get("issues_kept") or selected_issues(lap), "kind": "配布", "step": "配布の失敗"} +def settle_refused(env: Env, rec: dict, answers: dict) -> bool: + """配布待ちの周回へ届いた差し戻し・失敗の答えを書いて締め直す(まとめは流さない)。1 つでも書いたら True。""" + settled = False + for lap in pending_laps(rec): + a = answer_of(answers, (lap.get("release") or {}).get("request")) if lap["outcome"] == RELEASE_PENDING else None + if a and a.get("result") != "released": + settle_release(env, rec, lap, a) + settled = True + return settled + + def pending_step(env: Env, rec: dict, before: dict, apply_gate1=None) -> dict | None: """周回の境で、答えの届いた依頼を古い順に扱う(境の 2〜6)。まとめを流す周回があればその結果を返し、無ければ None。""" answers = read_answers(env, rec) if apply_gate1 is not None: apply_gate1(env, rec, answers) - for lap in pending_laps(rec): - a = answer_of(answers, (lap.get("release") or {}).get("request")) if lap["outcome"] == RELEASE_PENDING else None - if a and a.get("result") != "released": - settle_release(env, rec, lap, a) + settle_refused(env, rec, answers) lap = answered_lap(rec, answers) if lap is None: return None @@ -207,9 +215,12 @@ def pending_step(env: Env, rec: dict, before: dict, apply_gate1=None) -> dict | def pending_pipeline(env: Env, rec: dict, before: dict) -> dict: """`loop pending`: 答えの届いた保留の周回のまとめの pipeline だけを返す(止まったレーンの残りを一時レーンが流す。異常系 11)。""" answers = read_answers(env, rec) + settled = settle_refused(env, rec, answers) # 差し戻し・失敗も締め直して残さない(全レーンが止まった後の入口でも) lap = answered_lap(rec, answers) if lap is None: - return loop_ok("答えの届いた保留の周回は無い", rec, status="none") + if settled: + write_record(env, rec, before) + return loop_ok("答えの届いた保留の周回は無い" + ("(差し戻し・失敗は締め直した)" if settled else ""), rec, status="none") if lap["outcome"] == RELEASE_PENDING and not (lap["release"].get("answer") or {}).get("result"): settle_release(env, rec, lap, answer_of(answers, lap["release"]["request"])) return finish_result(env, rec, before, lap) diff --git a/plugins/ndf/scripts/release_lib/version_merge.py b/plugins/ndf/scripts/release_lib/version_merge.py index f5ca95b10..8e8c6e7f0 100644 --- a/plugins/ndf/scripts/release_lib/version_merge.py +++ b/plugins/ndf/scripts/release_lib/version_merge.py @@ -2,8 +2,10 @@ 承認の後に他の配布が起点のブランチの版数を上げていると、release/v<版> の取り込みが版数を持つ箇所で衝突する。 release/v<版> そのものへは起点のブランチを取り込まず(取り込むと承認の外のコミットが本番チャネルへ出る)、 -release/v<版> の先頭と起点のブランチの先頭を親に持つマージのコミットを作業ツリーに触れずに作る。衝突した塊が -版数の違いだけなら release/v<版> の側(本番の版)を取り、1 つでも版数の外が違えば解かずに止める。 +release/v<版> の先頭と起点のブランチの先頭を親に持つマージのコミットを作業ツリーに触れずに作る。解くのは、 +release/v<版> が分岐点から変えたパス(版上げが書いたファイル)の、release/v<版> が書いた版数の欄の違いだけで、 +その塊を release/v<版> の側(本番の版)で取る。それ以外のパス・版数の外・依存ライブラリなど他の版数が 1 つでも違えば +解かずに止める(起点のブランチの更新を本番の側の古い値へ戻さない)。 """ from __future__ import annotations @@ -26,12 +28,20 @@ def __init__(self, paths: list[str]): self.paths = paths -def _same_but_versions(a: list[str], b: list[str]) -> bool: - return [VERSION_RE.sub("", line) for line in a] == [VERSION_RE.sub("", line) for line in b] +def _same_but_versions(a: list[str], b: list[str], allowed: set[str]) -> bool: + """行ごとに版数の外が同じで、違う版数はどれもこちらの側が allowed(release/v<版> が書いた版数)のもの。""" + if len(a) != len(b): + return False + for x, y in zip(a, b): + if VERSION_RE.sub("", x) != VERSION_RE.sub("", y): + return False + if any(v != w and v not in allowed for v, w in zip(VERSION_RE.findall(x), VERSION_RE.findall(y))): + return False + return True -def resolve_text(text: str) -> str | None: - """衝突の印を持つ本文の塊がすべて版数の違いだけなら、こちら(release/v<版>)の側を取った本文。違えば None。""" +def resolve_text(text: str, allowed: set[str]) -> str | None: + """衝突の印を持つ本文の塊がすべて allowed の版数の違いだけなら、こちら(release/v<版>)の側を取った本文。違えば None。""" out: list[str] = [] ours: list[str] | None = None theirs: list[str] | None = None @@ -50,7 +60,7 @@ def resolve_text(text: str) -> str | None: else: ours.append(line) elif bare.startswith(_THEIRS): - if not _same_but_versions(ours, theirs): + if not _same_but_versions(ours, theirs, allowed): return None out.extend(ours) ours = theirs = None @@ -73,15 +83,28 @@ def _merge_tree(root, ours: str, theirs: str) -> tuple[str, dict[str, dict[int, return parts[0], stages +def _release_side(root, ours: str, theirs: str) -> tuple[set[str], set[str]]: + """(release/v<版> が分岐点から変えたパス, そこで書いた版数)。版上げ(bump)が書いた箇所だけを解く対象にする。""" + base = git(root, "merge-base", ours, theirs).stdout.strip() + paths = set(git(root, "diff", "--name-only", "-z", base, ours).stdout.split("\0")) - {""} + added = [ + line for line in git(root, "diff", "-U0", base, ours).stdout.splitlines() if line.startswith("+") and not line.startswith("+++") + ] + return paths, {v for line in added for v in VERSION_RE.findall(line)} + + def sync_commit(root, ours: str, theirs: str, message: str) -> tuple[str, list[str]]: """ours(release/v<版> の先頭)に theirs(起点のブランチの先頭)を取り込んだマージのコミットを作り、 - (コミット, 版数で解いたパス) を返す。作業ツリーと index には触れない。版数の外が衝突すれば VersionConflict。""" + (コミット, 版数で解いたパス) を返す。作業ツリーと index には触れない。release/v<版> が書いた版数の外が衝突すれば + VersionConflict。""" tree, stages = _merge_tree(root, ours, theirs) + paths, allowed = _release_side(root, ours, theirs) if stages else (set(), set()) resolved: dict[str, tuple[str, str]] = {} bad: list[str] = [] for path, by in sorted(stages.items()): - text = git(root, "cat-file", "blob", f"{tree}:{path}", check=False) if 2 in by and 3 in by else None - fixed = resolve_text(text.stdout) if text is not None and text.returncode == 0 else None + ok = 2 in by and 3 in by and path in paths + text = git(root, "cat-file", "blob", f"{tree}:{path}", check=False) if ok else None + fixed = resolve_text(text.stdout, allowed) if text is not None and text.returncode == 0 else None if fixed is None: bad.append(path) continue diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index 59d8c5db6..7d05fcdfb 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -350,6 +350,22 @@ def test_stop_during_release_waits_for_settle(env, tmp_path): assert pl.read_answer(main, "p1", rid)[0]["result"] == "released" +def test_stop_force_after_a_pending_stop_stops_the_helper(env, tmp_path): + """settle を待って停止を保留している間も inbox を読み、後から届いた --force で一時レーンも止めて終える。""" + main, log, states = env["main"], env["log"], env["states"] + states.write_text(json.dumps({"la": "running"})) + start(main) + pl.send_release_request(main, "p1", "la", [10], [1], tmp_path / "st") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release") + wid = pl.workflow_id("p1") + durable.send_to(pl.KIND, pl.record_identity(main, "p1"), wid, {"kind": "stop"}, "inbox", f"{wid}:inbox:stop") + time.sleep(0.6) + assert durable.status(wid) in pl.RUNNING + durable.send_to(pl.KIND, pl.record_identity(main, "p1"), wid, {"kind": "stop", "force": True}, "inbox", f"{wid}:inbox:stop:force") + out = durable.output_of(wid) + assert out["status"] == "stopped" and ["stop", "p1-rel1", "--json"] in calls(log, "stop") + + def test_notice_during_release_carries_launch(env, tmp_path): main, states = env["main"], env["states"] states.write_text(json.dumps({"la": "running"})) @@ -543,6 +559,13 @@ def test_settle_wakes_a_stopped_lane_of_the_range(env, tmp_path): until(lambda: pl.read_answer(main, "p1", rid)[0]) ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("wake") and e) assert ev["action"] == "notice" and ev["wake"] == [f"python3 {prun.RELAY} restart la"] + states.write_text(json.dumps({"la": "stopped", "lb": "awaiting_approval"})) # 後の知らせが pause を上書きしても + ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("lanes") == ["lb"] and e) + assert ev["wake"] == [f"python3 {prun.RELAY} restart la"] # レーンが動き出すまで起こす行を添え続ける + states.write_text(json.dumps({"la": "running", "lb": "running"})) + pl.send_release_request(main, "p1", "lb", [11], [2], tmp_path / "st2") + ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release" and e.get("n") == 2 and e) + assert "wake" not in ev def test_the_plan_does_not_finish_while_a_question_is_unanswered(env, tmp_path): diff --git a/plugins/ndf/scripts/tests/test_release_version_merge.py b/plugins/ndf/scripts/tests/test_release_version_merge.py index 0136ac046..6019cc784 100644 --- a/plugins/ndf/scripts/tests/test_release_version_merge.py +++ b/plugins/ndf/scripts/tests/test_release_version_merge.py @@ -62,12 +62,18 @@ def branch_release(root: Path, files: dict) -> str: def test_hunks_that_differ_only_in_versions_take_the_release_side(): text = 'x\n<<<<<<< ours\n "version": "10.28.0",\n=======\n "version": "10.28.0-dev.4",\n>>>>>>> theirs\ny\n' - assert version_merge.resolve_text(text) == 'x\n "version": "10.28.0",\ny\n' + assert version_merge.resolve_text(text, {"10.28.0"}) == 'x\n "version": "10.28.0",\ny\n' def test_hunks_that_differ_beyond_versions_are_not_resolved(): text = "<<<<<<< ours\nv 10.28.0 a\n=======\nv 10.28.0-dev.4 b\n>>>>>>> theirs\n" - assert version_merge.resolve_text(text) is None + assert version_merge.resolve_text(text, {"10.28.0"}) is None + + +def test_hunks_with_other_versions_than_the_release_wrote_are_not_resolved(): + """同じ塊にある依存ライブラリの版数の更新を、本番の側の古い値へ戻さない。""" + text = '<<<<<<< ours\n "version": "10.28.0",\n "dep": "1.2.3",\n=======\n "version": "10.28.0-dev.4",\n "dep": "1.2.4",\n>>>>>>> theirs\n' + assert version_merge.resolve_text(text, {"10.28.0"}) is None def test_sync_takes_the_release_version_and_keeps_the_release_tip_as_a_parent(repo): @@ -91,6 +97,17 @@ def test_sync_stops_when_a_place_without_versions_conflicts(repo): assert e.value.code == 1 and e.value.paths == ["src/a.py"] +def test_sync_stops_when_a_dependency_version_shares_the_hunk(repo): + """版数の衝突と同じ塊にある依存の版数の更新は、本番の側の古い値へ戻さずに止める。""" + dep = lambda v, d: f'{{\n "version": "{v}",\n "dep": "{d}"\n}}\n' # noqa: E731 + commit(repo, {"pkg.json": dep("10.27.14", "1.2.3")}, "pkg") + tip = branch_release(repo, {"pkg.json": dep("10.28.0", "1.2.3")}) + dev = commit(repo, {"pkg.json": dep("10.28.0-dev.4", "1.2.4")}, "dev") + with pytest.raises(version_merge.VersionConflict) as e: + version_merge.sync_commit(repo, tip, dev, "sync") + assert e.value.paths == ["pkg.json"] + + def test_sync_without_conflicts_is_a_plain_merge(repo): tip = branch_release(repo, {}) dev = commit(repo, {"src/b.py": "b = 2\n"}, "dev") diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index ab1aadac1..092f6809c 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -1411,6 +1411,15 @@ def test_loop_pending_returns_only_the_answered_close(env): assert out["metrics"]["status"] == "finish" and "loop finish l1 1" in out["metrics"]["pipeline"] +def test_loop_pending_settles_a_rejected_answer_and_keeps_the_record(env): + """全レーンが止まった後の入口(loop pending)でも、差し戻しの答えで保留の周回を締め直して記録へ残す。""" + request_lap(env) + env.answer_box[REQ] = {"answer": {"result": "rejected", "reason": "直す"}, "defer": None} + assert sl.cmd_pending(env, "l1")["metrics"]["status"] == "none" + lap = rec(env)["laps"][0] + assert lap["outcome"] == "人へ戻した" and lap["release"]["answer"]["result"] == "rejected" + + def test_hold_question_closes_the_lap_and_the_answer_resumes_the_stopped_plan(env): lap = lane_lap(env) impl = str(Path(lap["out"]) / "impl-11.json") @@ -1469,6 +1478,37 @@ def test_gate1_answers_are_applied_by_the_lane_at_the_boundary(env): assert any(c[:3] == ["gh", "pr", "edit"] and "design-approved" in c for c in env.calls) +def test_gate1_approval_for_a_moved_design_pr_is_asked_again(env, monkeypatch): + """問いの後に設計 PR へ push があれば、古い範囲への承認で design-approved と承認の記録を書かず、新しい先端で問い直す。""" + env.rank_box["out"] = rank_out(ms("01 近い", [11, 12, 13])) + env.answer_box = {} + env.answers = lambda plan, ids: {i: env.answer_box.get(i, {"answer": None, "defer": None}) for i in ids} + start(env) + sl_select.cmd_select(env, "l1") + sl.cmd_sprint(env, "l1", "standard", "1.0.0-dev.1", None, []) + lap = sl.current_lap(rec(env)) + sl_gate.cmd_gate(env, "l1", write_done(env, [{"plan": str(Path(lap["out"]) / "design-11.json"), "result": "関門"}])) + r = rec(env) + r["waiting"][0].update(request="lroot-g501-aaaaaaaaaaaa", plan="p1", pr=501, stamp="a" * 40, report="r.md", state="s.json") + sl.write_record(env, r) + set_state(env, sl.current_lap(r)) + sl.cmd_lap(env, "l1", False, None, []) + base = env.run + env.run = lambda cmd, cwd=None, stdin=None: (0, "b" * 40, "") if cmd[:3] == ["gh", "pr", "view"] else base(cmd, cwd=cwd, stdin=stdin) + sent = [] + monkeypatch.setattr( + sl_gate, + "lane_gate1", + lambda e, w, report, state: sent.append((report, state)) or {"request": "lroot-g501-bbbbbbbbbbbb", "stamp": "b" * 40}, + ) + env.answer_box["lroot-g501-aaaaaaaaaaaa"] = {"answer": {"result": "approved"}, "defer": None} + sl_select.cmd_select(env, "l1") + w = rec(env)["waiting"][0] + assert not w.get("answer") and w["request"] == "lroot-g501-bbbbbbbbbbbb" and w["restamped"] == "a" * 40 + assert sent == [("r.md", "s.json")] + assert not any(c[:3] == ["gh", "pr", "edit"] for c in env.calls) + + def test_gate_1_of_a_lane_sends_the_request_to_the_organizer(env, monkeypatch): lap = built_lap(env) report = design_report(env, "design-11", "https://github.com/o/r/pull/501") diff --git a/plugins/ndf/skills/development-workflow/references/parallel.md b/plugins/ndf/skills/development-workflow/references/parallel.md index 1b79e8f5a..5e266454a 100644 --- a/plugins/ndf/skills/development-workflow/references/parallel.md +++ b/plugins/ndf/skills/development-workflow/references/parallel.md @@ -17,11 +17,13 @@ ラッパーの外・tmux が無い・統括役が居ないときは 3 で終わり、`items` にレーンごとの手で起動する 1 行が並ぶ。 その 1 行を示して終える 2. `metrics.next` の `parallel.py wait p1` を背景で打ち、完了の通知を待つ([waiting.md](waiting.md) の形。前景の sleep で待たない) -3. 返った `metrics.action` ごとに次の 1 手を打ち、`wait p1 --after ` を背景で打ち直す +3. 返った `metrics.action` ごとに次の 1 手を打ち、`wait p1 --after ` を背景で打ち直す。どの `action` でも、 + `metrics.wake` があれば、まだ打っていない各行(配布の答えを受けた止まっているレーンを起こす `relay.py restart <レーン>`)を打つ + (そのレーンが動き出すまで後の知らせにも添えて届く) | `wait` が返したもの | すること | | --- | --- | -| `notice` | 承認待ちのレーンの `metrics.attach` の 1 行を出す。`metrics.release` があり、その `seq` の起動をまだ打っていなければ、`metrics.release.spawn` の 1 行も打つ(知らせが重なって `release` / `confirmed` を読み逃しても起動を失わない)。`metrics.wake` があれば、その各行(配布の答えを受けた止まっているレーンを起こす `relay.py restart <レーン>`)を打つ | +| `notice` | 承認待ちのレーンの `metrics.attach` の 1 行を出す。`metrics.release` があり、その `seq` の起動をまだ打っていなければ、`metrics.release.spawn` の 1 行も打つ(知らせが重なって `release` / `confirmed` を読み逃しても起動を失わない) | | `questions` | 届いている依頼(`items`)を下の「問い方」で問い、依頼ごとに答えを送る | | `release` / `confirmed` | `metrics.spawn` の 1 行(一時レーンの起動)を打つ。一時レーンが配布のプランを流し、結果を返す | | `held` | `metrics.spawn` の 1 行を打つ。一時レーンが、止まったループのレーンの答えを受けた保留の周回の残りを流す(終える前の後片付け) | diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json index 1853ee573..1778c70c3 100644 --- a/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/scripts/lib/parallel_run.py", "name": "", "kind": "lines", "reason": "#2436 で並列の計画の耐久ワークフロー(耐久ステップ・_Run の周回・一時レーンの起動・外の入口)を 1 モジュールに置いて 665 行。500 行以下にしたら消す。分けるなら、外の世界の読み出し(relay・base_tracking_ref・release_plugin・git_head・dev_version)と一時レーンの組み立て(helper_spawn・present_approval・helper_task)を別のモジュールへ移す", "lines": 665} +{"path": "plugins/ndf/scripts/lib/parallel_run.py", "name": "", "kind": "lines", "reason": "#2436 で並列の計画の耐久ワークフロー(耐久ステップ・_Run の周回・一時レーンの起動・外の入口)を 1 モジュールに置いて 669 行。500 行以下にしたら消す。分けるなら、外の世界の読み出し(relay・base_tracking_ref・release_plugin・git_head・dev_version)と一時レーンの組み立て(helper_spawn・present_approval・helper_task)を別のモジュールへ移す", "lines": 669} diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop.py--lines.json index 8503ba47d..bcb3a8a78 100644 --- a/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/scripts/lib/sprint_loop.py", "name": "", "kind": "lines", "reason": "492 行だったところへ #2436(レーンの配布待ち・質問待ち・保留の周回の CLI)で増えて 584 行。500 行以下にしたら消す", "lines": 584} +{"path": "plugins/ndf/scripts/lib/sprint_loop.py", "name": "", "kind": "lines", "reason": "492 行だったところへ #2436(レーンの配布待ち・質問待ち・保留の周回の CLI)で増えて 587 行。500 行以下にしたら消す", "lines": 587} diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json index cdc42cbf4..b93dccb79 100644 --- a/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/scripts/lib/sprint_loop_gate.py", "name": "", "kind": "lines", "reason": "498 行だったところへ #2436(承認ゲートの問いをオーガナイザーへ送る経路・レーンの配布待ち)で増えて 564 行。500 行以下にしたら消す。保留の周回は sprint_loop_gate2.py へ分けてある", "lines": 564} +{"path": "plugins/ndf/scripts/lib/sprint_loop_gate.py", "name": "", "kind": "lines", "reason": "498 行だったところへ #2436(承認ゲートの問いをオーガナイザーへ送る経路・レーンの配布待ち)で増えて 582 行。500 行以下にしたら消す。保留の周回は sprint_loop_gate2.py へ分けてある", "lines": 582} From 22306b858cd14c07180d7d9661467941acc29a92 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 13:01:56 +0000 Subject: [PATCH 57/70] =?UTF-8?q?Fix:=20=E4=B8=80=E6=99=82=E3=83=AC?= =?UTF-8?q?=E3=83=BC=E3=83=B3=E3=81=AE=E5=BD=B9=E5=89=B2=E3=82=92=20role.j?= =?UTF-8?q?son=20=E3=81=B8=E6=9B=B8=E3=81=8F=E3=83=BB=E4=BE=9D=E9=A0=BC?= =?UTF-8?q?=E3=81=AE=E5=85=A8=20PR=20=E3=81=8C=E7=AF=84=E5=9B=B2=E3=81=AB?= =?UTF-8?q?=E5=85=A5=E3=81=A3=E3=81=9F=E3=81=A8=E3=81=8D=E3=81=A0=E3=81=91?= =?UTF-8?q?=E7=AD=94=E3=81=88=E3=82=8B=E3=83=BB=E8=A8=AD=E8=A8=88=E3=81=AE?= =?UTF-8?q?=E6=89=BF=E8=AA=8D=E3=82=92=E5=BD=93=E3=81=A6=E3=82=8B=E3=81=BE?= =?UTF-8?q?=E3=81=A7=E8=A8=88=E7=94=BB=E3=82=92=E7=B5=82=E3=81=88=E3=81=AA?= =?UTF-8?q?=E3=81=84=E3=83=BB=E8=87=AA=E5=88=86=E3=81=A7=E7=AB=8B=E3=81=A6?= =?UTF-8?q?=E3=81=9F=E5=95=8F=E3=81=84=E3=81=AE=E3=80=8C=E6=AD=A2=E3=82=81?= =?UTF-8?q?=E3=82=8B=E3=80=8D=E3=82=92=E5=BD=93=E3=81=A6=E3=82=8B=E3=83=BB?= =?UTF-8?q?=E9=85=8D=E5=B8=83=E6=B8=88=E3=81=BF=E3=82=92=E6=9C=AC=E7=95=AA?= =?UTF-8?q?=E3=83=81=E3=83=A3=E3=83=8D=E3=83=AB=E3=81=A7=E7=85=A7=E5=90=88?= =?UTF-8?q?=E3=81=99=E3=82=8B=EF=BC=88#2436=20=E3=81=AE=20cross-review=20r?= =?UTF-8?q?ound=207=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - relay_lib/hub_client.write_name: spawn --role/--plan で起動したラッパーは役割のファイルを書く - parallel_release.answerable: 依頼の Pull Request がすべて範囲に入るときだけ答える - parallel_run: 答えたのに持ち主が当てていない設計の承認があれば終えず、起こす 1 行を添える。 spawn の問いの「止める」は起こし直しをやめ、helper の問いの「止める」は配布を失敗として締める - parallel.py answer: 自分で立てた問いの metrics.next は選んだ答えの commands の 1 行 - parallel_lane.merged_on_production: origin/main 固定でなく repo.production_branch の本番チャネルで照合する Co-Authored-By: Claude Opus 5.5 --- .../plugins__ndf__scripts__hook.py.json | 2 + .../plugins__ndf__scripts__relay.py.json | 2 + plugins/ndf/contracts/env/NDF_RELAY_PLAN.json | 5 ++ plugins/ndf/contracts/env/NDF_RELAY_ROLE.json | 5 ++ plugins/ndf/contracts/index.md | 2 + plugins/ndf/scripts/lib/parallel_lane.py | 13 ++- plugins/ndf/scripts/lib/parallel_release.py | 6 +- plugins/ndf/scripts/lib/parallel_run.py | 81 +++++++++++++++---- plugins/ndf/scripts/parallel.py | 16 ++-- plugins/ndf/scripts/relay_lib/hub_client.py | 8 +- .../ndf/scripts/tests/test_parallel_lane.py | 20 +++++ .../ndf/scripts/tests/test_parallel_plan.py | 2 + .../ndf/scripts/tests/test_parallel_run.py | 59 ++++++++++++++ plugins/ndf/scripts/tests/test_relay_hub.py | 17 ++++ .../references/parallel.md | 5 +- ..._scripts__lib__parallel_run.py--lines.json | 2 +- 16 files changed, 214 insertions(+), 31 deletions(-) create mode 100644 plugins/ndf/contracts/env/NDF_RELAY_PLAN.json create mode 100644 plugins/ndf/contracts/env/NDF_RELAY_ROLE.json diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__hook.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__hook.py.json index a932023bf..55f7a9630 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__hook.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__hook.py.json @@ -30,7 +30,9 @@ "NDF_RELAY_DEPTH", "NDF_RELAY_DIR", "NDF_RELAY_NAME", + "NDF_RELAY_PLAN", "NDF_RELAY_REEXEC", + "NDF_RELAY_ROLE", "NDF_RELAY_SPAWNED", "NDF_RELAY_TMUX", "NDF_RELAY_UV", diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__relay.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__relay.py.json index 6c1b308bb..087a4e2f4 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__relay.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__relay.py.json @@ -19,7 +19,9 @@ "NDF_RELAY_DEPTH", "NDF_RELAY_DIR", "NDF_RELAY_NAME", + "NDF_RELAY_PLAN", "NDF_RELAY_REEXEC", + "NDF_RELAY_ROLE", "NDF_RELAY_SPAWNED", "NDF_RELAY_TMUX", "NDF_RELAY_UV", diff --git a/plugins/ndf/contracts/env/NDF_RELAY_PLAN.json b/plugins/ndf/contracts/env/NDF_RELAY_PLAN.json new file mode 100644 index 000000000..79f1952ba --- /dev/null +++ b/plugins/ndf/contracts/env/NDF_RELAY_PLAN.json @@ -0,0 +1,5 @@ +{ + "name": "NDF_RELAY_PLAN", + "set_by": "user", + "summary": "統括役が `spawn --role <役割> --plan <計画名>` で起動したラッパーの並列の計画の名前(#2436)。NDF_RELAY_ROLE と一緒に役割のファイル(role.json)へ書く(plugins/ndf/scripts/relay_lib/hub.py・hub_client.py)" +} diff --git a/plugins/ndf/contracts/env/NDF_RELAY_ROLE.json b/plugins/ndf/contracts/env/NDF_RELAY_ROLE.json new file mode 100644 index 000000000..4ee477790 --- /dev/null +++ b/plugins/ndf/contracts/env/NDF_RELAY_ROLE.json @@ -0,0 +1,5 @@ +{ + "name": "NDF_RELAY_ROLE", + "set_by": "user", + "summary": "統括役が `spawn --role <役割> --plan <計画名>` で起動したラッパーの役割(#2436)。relay.py run が作業ディレクトリの役割のファイル(role.json)へ書く(plugins/ndf/scripts/relay_lib/hub.py・hub_client.py)" +} diff --git a/plugins/ndf/contracts/index.md b/plugins/ndf/contracts/index.md index 361aa0a1d..5de596724 100644 --- a/plugins/ndf/contracts/index.md +++ b/plugins/ndf/contracts/index.md @@ -124,7 +124,9 @@ | `NDF_RELAY_DIR` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py` | | `NDF_RELAY_MAX_NAMED` | user | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | | `NDF_RELAY_NAME` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | +| `NDF_RELAY_PLAN` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | | `NDF_RELAY_REEXEC` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | +| `NDF_RELAY_ROLE` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | | `NDF_RELAY_SPAWNED` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | | `NDF_RELAY_TMUX` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | | `NDF_RELAY_UV` | user | `plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/relay.py` | diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py index b0987d74d..ae0ae8c90 100644 --- a/plugins/ndf/scripts/lib/parallel_lane.py +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -70,16 +70,23 @@ def lane_plans(main: Path, lane: Optional[str] = None) -> Optional[int]: return got[1].plans if got else None -def merged_on_main(root: Path, prs: list[int], run=subprocess.run) -> bool: - """`prs` のマージのコミットがすべて `origin/main` にあるか(I15)。1 本でも分からなければ False。""" +def merged_on_production(root: Path, prs: list[int], run=subprocess.run) -> bool: + """`prs` のマージのコミットがすべて本番チャネル(`repo.production_branch`)の `origin/<本番>` にあるか(I15)。 + + 本番チャネルが決まらないか、1 本でも分からなければ False。""" + import repo + if not prs: return False + prod = repo.production_branch(root)[0] + if not prod: + return False for n in prs: got = run(["gh", "pr", "view", str(n), "--json", "mergeCommit", "-q", ".mergeCommit.oid"], cwd=root, capture_output=True, text=True) sha = (got.stdout or "").strip() if got.returncode != 0 or not sha: return False - anc = run(["git", "merge-base", "--is-ancestor", sha, "origin/main"], cwd=root, capture_output=True, text=True) + anc = run(["git", "merge-base", "--is-ancestor", sha, f"origin/{prod}"], cwd=root, capture_output=True, text=True) if anc.returncode != 0: return False return True diff --git a/plugins/ndf/scripts/lib/parallel_release.py b/plugins/ndf/scripts/lib/parallel_release.py index 999598ffd..e9f6b55b0 100644 --- a/plugins/ndf/scripts/lib/parallel_release.py +++ b/plugins/ndf/scripts/lib/parallel_release.py @@ -52,9 +52,11 @@ def map_lanes(plan: pp.ParallelPlan, prs: list[int], closes: dict[int, list[int] def answerable(pending: Iterable[dict], prs: Iterable[int]) -> list[str]: - """範囲 `prs` に Pull Request が入る依頼の ID(その配布の答えを受ける。I9・I15)。""" + """依頼の Pull Request がすべて範囲 `prs` に入る依頼の ID(その配布の答えを受ける。I9・I15)。 + + 一部だけが入る依頼は答えず、次の配布へ回す(残りを本番へ出さずに配布済みと答えない)。""" got = set(prs) - return [r["id"] for r in pending if set(r.get("prs") or []) & got] + return [r["id"] for r in pending if (want := set(r.get("prs") or [])) and want <= got] def breakdown_md(mapped: dict, added: Optional[dict] = None) -> str: diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 68d4b7fe1..55c7f13c6 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -178,6 +178,33 @@ def held_loops(main: Path, name: str, answered: list[str]) -> list[str]: return out +@durable.step(name="parallel.design-held") +def step_design_held(main: str, name: str, answered: list[str]) -> list[str]: + return design_held(Path(main), name, answered) + + +def design_held(main: Path, name: str, answered: list[str]) -> list[str]: + """設計の承認の依頼に答えたのに、持ち主のループのレーンが答えをまだ当てていないレーンの名前(#2436 の 6f)。 + + 答え(`answer-<依頼>`)は持ち主が周回の境で当てる。当てる前に計画を終えると、起こしたレーンが以後の配布の + 依頼を送れないため、当てるまで計画を終えない。""" + try: + plan = pp.ParallelPlan.model_validate(pp.load_plan(main, name), context={"max_plans": 10**6, "max_named": 10**6}) + except (OSError, ValueError): + return [] + out = [] + for ln in plan.lanes: + if ln.input.kind != "loop": + continue + try: + rec = json.loads((Path(main) / ".ndf" / "loop" / ln.input.loop / "loop.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + if any(w.get("request") in answered and not w.get("answer") for w in rec.get("waiting") or []): + out.append(ln.name) + return out + + @durable.step(name="parallel.build") def step_build(main: str, name: str, seq: int, k: int, requests: Optional[list[dict]] = None) -> dict: """まとめた配布の提示のプランと本番のプランを書き、受けている依頼を `releases/requests.json` へ書く @@ -328,6 +355,20 @@ def reply(self, msg: dict) -> None: durable.set_event(f"answer-{each}", {k: msg.get(k) for k in ("result", "note", "choice", "stamp", "at")}) self.questions = [q for q in self.questions if q.get("id") != rid] self.publish_questions() + self.apply_own(q, list(msg.get("choice") or [])) + + def apply_own(self, q: dict, choice: list[str]) -> None: + """耐久ワークフローが自分で立てた問いへの答えを当てる。「止める」は、起動できないレーンなら起こし直しをやめて + 止まったものと数え、配布の途中で止まった一時レーンなら配布を失敗として締める(依頼へ failed を答える)。""" + if "止める" not in choice: + return + if q.get("kind") == "spawn" and q.get("lane") in self.lanes: + self.lanes[q["lane"]].update(started=True, state="stopped", given_up=True) + elif q.get("kind") == "helper" and (rel := self.current) is not None and q.get("n") == rel["n"]: + if not rel.get("prs"): # 提示の前に止まったなら、この配布へ入れた依頼の Pull Request を範囲として締める + own = {n for r in self.pending if r["id"] in rel.get("requests", []) for n in r.get("prs") or []} + rel["prs"] = step_presented(rel["dirs"][-1]).get("prs") or sorted(own) + self.settle(rel, {"result": "failed", "reason": "配布の途中で止まった一時レーンを、人が止めると答えた"}) def ask(self, kind: str, rid: str, lane: str, question: str, options: list[str], **extra) -> None: """耐久ワークフローが自分で立てる問い(動かないレーン・一時レーンの停止・起動の断り)。同じ id は 1 回だけ。""" @@ -516,17 +557,8 @@ def parallel_workflow(main: str, name: str) -> dict: run.answer_from_ranges() if run.pending: run.open_release() - if not run.stop and _run_finished(run): - held = step_held_loops(main, name, sorted(run.answered)) - if not held: - step_finish(main, name, run.progress()) - return run.summary("finished") - # 止まったレーンの保留の周回のまとめを一時レーンで流してから終える(異常系 11)。一時レーンは 1 本ずつ起こす - todo = [x for x in held if x not in run.held_spawned] - if todo and not set(run.held_spawned) & set(held): - run.held_spawned.append(todo[0]) - seq = len(run.releases) + len(run.held_spawned) - run.pause("held", loops=[todo[0]], spawn=helper_spawn(name, seq, 0, pending=todo[0])) + if not run.stop and _run_finished(run) and (out := _finish_or_hold(run, main, name)) is not None: + return out run.take(durable.recv("inbox", wait if run.current is None else min(wait, 30))) if run.current is not None: msg = durable.recv("resume", min(wait, 30)) @@ -536,6 +568,27 @@ def parallel_workflow(main: str, name: str) -> dict: _observe_run(run, main) +def _finish_or_hold(run: _Run, main: str, name: str) -> Optional[dict]: + """全レーンが止まった後: 終えられれば要約を返す。答えを当てていない設計の承認か、保留の周回があれば None。""" + designs = step_design_held(main, name, sorted(run.answered)) + if designs: # 持ち主が設計の承認の答えを当てるまで終えない。起こす 1 行を知らせへ添え続ける(6f) + if set(designs) - set(run.wake): + run.wake = sorted({*run.wake, *designs}) + run.pause("notice", lanes=[], attach=[]) + return None + held = step_held_loops(main, name, sorted(run.answered)) + if not held: + step_finish(main, name, run.progress()) + return run.summary("finished") + # 止まったレーンの保留の周回のまとめを一時レーンで流してから終える(異常系 11)。一時レーンは 1 本ずつ起こす + todo = [x for x in held if x not in run.held_spawned] + if todo and not set(run.held_spawned) & set(held): + run.held_spawned.append(todo[0]) + seq = len(run.releases) + len(run.held_spawned) + run.pause("held", loops=[todo[0]], spawn=helper_spawn(name, seq, 0, pending=todo[0])) + return None + + def _started(run: _Run) -> list[str]: return [n for n, st in run.lanes.items() if st.get("started")] @@ -556,10 +609,10 @@ def _spawn_ready(run: _Run, main: str) -> None: run.lanes[n].update(got) if got.get("refused"): run.refusals[n] = run.refusals.get(n, 0) + 1 - if run.refusals[n] == SPAWN_REFUSALS: # 3 回続けて断られたら理由を添えて問う(異常系 17) + if run.refusals[n] % SPAWN_REFUSALS == 0: # 3 回続けて断られるたびに理由を添えて問う(異常系 17) run.ask( "spawn", - f"{run.name}-spawn-{n}", + f"{run.name}-spawn-{n}-{run.refusals[n]}", n, f"レーン {n} を {SPAWN_REFUSALS} 回起動できない({got['refused']})", ["起こし直す", "止める"], @@ -620,7 +673,7 @@ def _ask_helper(run: _Run, rows: dict) -> None: run.helper_asked = run.launch["seq"] run.ask( "helper", f"{name}-h{run.launch['seq']}", name, f"一時レーン {name} が配布 {rel['n']} の途中で止まった(settle も承認ゲート 2 の依頼も無い)", - ["続きから起こし直す", "止める"], commands={"続きから起こし直す": run.launch.get("spawn")}, + ["続きから起こし直す", "止める"], commands={"続きから起こし直す": run.launch.get("spawn")}, n=rel["n"], ) # fmt: skip diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index 884873751..407d8c600 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -303,10 +303,10 @@ def cmd_request(a) -> None: main = main_of() if pl.run_status(main, a.name) not in pl.RUNNING: reply("stopped", f"並列の計画 {a.name} が動いていない", code=1) - if a.prs and pl.merged_on_main(main, a.prs): + if a.prs and pl.merged_on_production(main, a.prs): version = pl.released_version(main, a.name, a.prs) pl.write_request(Path(a.state), {"result": "released", "version": version, "prs": a.prs}) - reply("ok", "自分の Pull Request はすでに main にある。依頼を送らずに続きへ進む", result="released", version=version) + reply("ok", "自分の Pull Request はすでに本番チャネルにある。依頼を送らずに続きへ進む", result="released", version=version) rid = pl.send_release_request(main, a.name, a.lane, a.prs, a.issues, Path(a.state)) pl.write_request(Path(a.state), {"id": rid, "plan": a.name, "lane": a.lane, "prs": a.prs}) reply("gate", f"配布の依頼 {rid} を送った(配布待ち)。答えは parallel.py await で読む", code=10, id=rid, result="requested") @@ -364,15 +364,15 @@ def cmd_request_gate2(a) -> None: reply("ok", f"承認ゲート 2 の依頼 {rid} をオーガナイザーへ送った。答えを待つ間は止まる", id=rid) -def _owner_restart(main: Path, name: str, rid: str) -> str | None: - """答えた依頼の持ち主のレーンが止まっていれば、起こす 1 行(`relay.py restart <レーン>`)。動いていれば None。""" +def _owner_restart(main: Path, name: str, rid: str, choice: list[str] | None = None) -> str | None: + """答えの後の 1 行。自分で立てた問い(stall・helper・spawn)は選んだ答えの commands、ほかは止まった持ち主の restart。""" import durable import parallel_run as pr_run items = (durable.event_from(pl.KIND, pl.record_identity(main, name), pl.workflow_id(name), "questions", 0) or {}).get("items") or [] - lane = next((q.get("lane") for q in items if q.get("id") == rid), None) - if not lane: - return None + q = next((q for q in items if q.get("id") == rid), {}) + if q.get("kind") in ("stall", "helper", "spawn") or not (lane := q.get("lane")): + return next((q["commands"][c] for c in choice or [] if (q.get("commands") or {}).get(c)), None) code, got = pr_run.relay(main, "ls", "--json") state = next((i.get("state") for i in got.get("items") or [] if i.get("name") == lane), None) if code == 0 else None return None if state in ("running", "awaiting_approval") else f"python3 {pr_run.RELAY} restart {lane}" @@ -391,7 +391,7 @@ def cmd_answer(a) -> None: except OSError as exc: reply("stopped", f"指摘ファイルを読めない: {exc}", code=2) result = "approved" if a.approved else "revise" if a.revise else "rejected" if a.rejected else "answered" - nxt = _owner_restart(main, a.name, a.id) + nxt = _owner_restart(main, a.name, a.id, a.choice) pl.send_answer(main, a.name, a.id, result, note=note, choice=a.choice) reply("ok", f"依頼 {a.id} へ答え({result})を送った", id=a.id, result=result, next=nxt) diff --git a/plugins/ndf/scripts/relay_lib/hub_client.py b/plugins/ndf/scripts/relay_lib/hub_client.py index bdead5e25..a3ecc8e2f 100644 --- a/plugins/ndf/scripts/relay_lib/hub_client.py +++ b/plugins/ndf/scripts/relay_lib/hub_client.py @@ -116,10 +116,16 @@ def release_hub(hub: hb.Hub | None) -> None: def write_name(relay_dir: str, hub: hb.Hub | None) -> None: - """名前つきのラッパーと統括役(`t1`)の作業ディレクトリへ名前を書く。""" + """名前つきのラッパーと統括役(`t1`)の作業ディレクトリへ名前を書く。 + + `spawn --role <役割> --plan <計画名>` で起動したラッパー(`NDF_RELAY_ROLE`・`NDF_RELAY_PLAN`)は、役割のファイルも + 書く(問いの転送と `helper` の数えが読む。#2436)。""" name = os.environ.get("NDF_RELAY_NAME") or ("t1" if hub is not None else None) if name: _write_file(os.path.join(relay_dir, NAME_FILE), (name + "\n").encode(), 0o600) + role, plan = os.environ.get("NDF_RELAY_ROLE"), os.environ.get("NDF_RELAY_PLAN") + if role in hb.ROLES and plan: + write_json_atomic(os.path.join(relay_dir, hb.ROLE_FILE), {"role": role, "plan": plan, "at": stamp()}) @contextlib.contextmanager diff --git a/plugins/ndf/scripts/tests/test_parallel_lane.py b/plugins/ndf/scripts/tests/test_parallel_lane.py index acce394f6..1ceed2413 100644 --- a/plugins/ndf/scripts/tests/test_parallel_lane.py +++ b/plugins/ndf/scripts/tests/test_parallel_lane.py @@ -180,3 +180,23 @@ def test_lane_prod_stage_chains_after_release(monkeypatch, tmp_path): a = argparse.Namespace(worktree=str(tmp_path), design=[], issue=[1], version=None, pace="normal") waves = sprint.sprint_plans(a) assert waves[-1]["name"] == "本番" and waves[-1]["then_of"] == waves[0]["name"] # 先頭の command へ --then でつなぐ + + +def test_merged_on_production_checks_the_production_branch(tmp_path, monkeypatch): + """配布済みの照合は origin/main 固定でなく、本番チャネル(repo.production_branch)の先を見る。""" + import subprocess + + import repo + + monkeypatch.setattr(repo, "production_branch", lambda _root, decl=None: ("live", "宣言")) + seen = [] + + def run(cmd, **_kw): + seen.append(cmd) + out = "abc\n" if cmd[:2] == ["gh", "pr"] else "" + return subprocess.CompletedProcess(cmd, 0, out, "") + + assert pl.merged_on_production(tmp_path, [7], run=run) is True + assert seen[-1][-1] == "origin/live" + monkeypatch.setattr(repo, "production_branch", lambda _root, decl=None: (None, "無い")) + assert pl.merged_on_production(tmp_path, [7], run=run) is False diff --git a/plugins/ndf/scripts/tests/test_parallel_plan.py b/plugins/ndf/scripts/tests/test_parallel_plan.py index 627061d4c..6940a891a 100644 --- a/plugins/ndf/scripts/tests/test_parallel_plan.py +++ b/plugins/ndf/scripts/tests/test_parallel_plan.py @@ -194,6 +194,8 @@ def test_range_maps_unrequested_lane_and_orphans(): def test_answerable_and_moved(): pending = [{"id": "a", "prs": [1]}, {"id": "b", "prs": [5]}] assert prl.answerable(pending, [1, 2]) == ["a"] + # 一部だけが範囲に入る依頼は答えない(残りを出さずに配布済みと答えない) + assert prl.answerable([{"id": "c", "prs": [1, 3]}, {"id": "d", "prs": []}], [1, 2]) == [] assert prl.moved("abc", "abc") is False and prl.moved("abc", "def") is True diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index 7d05fcdfb..8b7d113a7 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -516,6 +516,52 @@ def test_a_stopped_helper_without_settle_is_asked(env, tmp_path): assert item["options"] == ["続きから起こし直す", "止める"] and "p1-rel1" in item["question"] +def test_stop_answer_to_a_stopped_helper_fails_the_release(env, tmp_path): + """一時レーンの問いへ「止める」と答えると、配布を失敗として締め、依頼へ failed を答える。""" + main, states = env["main"], env["states"] + start(main) + rid = pl.send_release_request(main, "p1", "la", [10], [1], tmp_path / "st") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release") + states.write_text(json.dumps({"la": "running", "p1-rel1": {"state": "stopped", "role": "helper"}})) + item = until(lambda: [i for i in questions(main)["items"] if i["kind"] == "helper"])[0] + pl.send_answer(main, "p1", item["id"], "answered", choice=["止める"]) + ans = until(lambda: pl.read_answer(main, "p1", rid)[0]) + assert ans["result"] == "failed" + + +def test_stop_answer_to_spawn_refusals_gives_up_the_lane(env, monkeypatch): + """起動できないレーンの問いへ「止める」と答えると、起こし直しをやめて止まったものと数える。""" + monkeypatch.setenv("FAKE_REFUSE", "la") + main, log = env["main"], env["log"] + start(main) + item = until(lambda: [i for i in questions(main)["items"] if i["kind"] == "spawn"])[0] + pl.send_answer(main, "p1", item["id"], "answered", choice=["止める"]) + until(lambda: not questions(main)["items"]) + time.sleep(1.0) + before = len([c for c in calls(log, "spawn") if c[2] == "la"]) + time.sleep(1.0) + assert len([c for c in calls(log, "spawn") if c[2] == "la"]) == before + + +def test_the_plan_waits_until_the_owner_applies_a_design_answer(env, monkeypatch, tmp_path): + """全レーンが止まった後に設計の承認へ答えても、持ち主が答えを当てるまで計画を終えず、起こす 1 行を添える(6f)。""" + main, states = env["main"], env["states"] + applied = {"v": False} + monkeypatch.setattr(prun, "design_held", lambda m, n, answered: ["la"] if answered and not applied["v"] else []) + states.write_text(json.dumps({"la": "running"})) + start(main) + rid = pl.request_ask(main, "p1", "la", ASK, str(tmp_path), "d1") + until(lambda: questions(main)["items"]) + states.write_text(json.dumps({"la": "stopped", "lb": "stopped"})) + until(lambda: "lb" in spawned(env["log"])) + pl.send_answer(main, "p1", rid, "answered", choice=["済んだ"]) + ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("wake") and e) + assert ev["wake"] == [f"python3 {prun.RELAY} restart la"] + assert durable.status(pl.workflow_id("p1")) in pl.RUNNING + applied["v"] = True + assert durable.output_of(pl.workflow_id("p1"))["status"] == "finished" + + def test_stop_force_does_not_wait_for_settle_and_stops_the_helper(env, tmp_path): main, log = env["main"], env["log"] start(main) @@ -596,3 +642,16 @@ def q(n): pl.request_ask(main, "p1", "la", q(12), str(tmp_path), "y1") pl.request_ask(main, "p1", "lb", q(34), str(tmp_path), "y2") until(lambda: len(questions(main)["items"]) == 2) + + +def test_answer_to_an_own_question_returns_the_chosen_command(monkeypatch, tmp_path): + """耐久ワークフローが立てた問いの答えの後の 1 行は、選んだ答えの commands で、持ち主を起こす restart ではない。""" + import importlib.util + + spec = importlib.util.spec_from_file_location("parallel_cli", Path(prun.__file__).resolve().parents[1] / "parallel.py") + cli = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cli) + item = {"id": "h1", "kind": "helper", "lane": "p1-rel1", "commands": {"続きから起こし直す": "SPAWN"}} + monkeypatch.setattr(durable, "event_from", lambda *_a, **_k: {"items": [item]}) + assert cli._owner_restart(tmp_path, "p1", "h1", ["続きから起こし直す"]) == "SPAWN" + assert cli._owner_restart(tmp_path, "p1", "h1", ["止める"]) is None diff --git a/plugins/ndf/scripts/tests/test_relay_hub.py b/plugins/ndf/scripts/tests/test_relay_hub.py index 063922725..dff0e8a69 100644 --- a/plugins/ndf/scripts/tests/test_relay_hub.py +++ b/plugins/ndf/scripts/tests/test_relay_hub.py @@ -628,3 +628,20 @@ def test_process_restart_of_running_name_is_taken(launched, dirs, tmp_path): _write_registry({"a": {"session": "a", "cwd": str(tmp_path), "args": [], "created_at": "x", "by": "spawn"}}) res = hb.Hub(None).process({"kind": "restart", "name": "a"}) assert res["items"][0]["result"] == hb.NAME_TAKEN and launched == [] + + +def test_write_name_keeps_the_spawned_role(tmp_path, monkeypatch): + """`spawn --role helper --plan <計画名>` で起動したラッパーは、役割のファイルを作業ディレクトリへ書く(#2436)。""" + import parallel_files + + monkeypatch.setenv("NDF_RELAY_NAME", "ph-p1-1") + monkeypatch.setenv("NDF_RELAY_ROLE", "helper") + monkeypatch.setenv("NDF_RELAY_PLAN", "p1") + hc.write_name(str(tmp_path), None) + assert hb.read_role(str(tmp_path))["plan"] == "p1" + assert parallel_files.helper_plan_of(str(tmp_path)) == "p1" + other = tmp_path / "other" + other.mkdir() + monkeypatch.delenv("NDF_RELAY_ROLE") + hc.write_name(str(other), None) + assert hb.read_role(str(other)) is None diff --git a/plugins/ndf/skills/development-workflow/references/parallel.md b/plugins/ndf/skills/development-workflow/references/parallel.md index 5e266454a..ce4987cd7 100644 --- a/plugins/ndf/skills/development-workflow/references/parallel.md +++ b/plugins/ndf/skills/development-workflow/references/parallel.md @@ -39,7 +39,7 @@ 2. 依頼 1 件を問い 1 つにし、5 件以上の区分は届いた順に 4 件ずつ続けて呼ぶ。各問いにはレーン名・課題・設計 PR の URL か作業の場所を添える 3. `ndf-ask:human-action` の依頼は `attach`(`relay.py attach <レーン>`)の 1 行を添えて「済んだ」「まだ」で問う。 `merged` があれば、同じ原因でまとめた各レーンの元の文面を並べる(答えは全レーンへ届く) -4. 耐久ワークフローが自分で立てる問い(`kind` が `stall`・`helper`・`spawn`)は `options` で問い、選んだ答えの `commands` の 1 行があれば打つ +4. 耐久ワークフローが自分で立てる問い(`kind` が `stall`・`helper`・`spawn`)は `options` で問う。選んだ答えの `commands` の 1 行は、答えを送った `answer` が `metrics.next` に返す 5. 答えを送り、`wait` を打ち直す。答えの間に新しい依頼が届いていれば `questions` が続けて返る | 依頼の `kind` | 問いの答え | 送るコマンド | @@ -49,7 +49,8 @@ | `ask`・`stall`・`helper`・`spawn` | 依頼の選択肢 | `parallel.py answer p1 --choice <選んだ答え>` | `answer` は答えのイベントを立てるだけで、ラベル・記録・直しは持ち主のレーンが周回の境で行う。持ち主のレーンが止まっていれば -`metrics.next` に `relay.py restart <レーン>` の 1 行が返るので、それを打つ。 +`metrics.next` に `relay.py restart <レーン>` の 1 行が返るので、それを打つ(耐久ワークフローが自分で立てた問いでは、選んだ答えの +`commands` の 1 行が返る)。 承認ゲート 2 は、提示 1 なら「承認」「差し戻す」で問い、`parallel.py confirm p1 <連番>` か `parallel.py settle p1 <連番> --rejected` を打つ。出し直しの提示(`represent: true`)なら加わった範囲を添えて diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json index 1778c70c3..a7bb42f51 100644 --- a/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/scripts/lib/parallel_run.py", "name": "", "kind": "lines", "reason": "#2436 で並列の計画の耐久ワークフロー(耐久ステップ・_Run の周回・一時レーンの起動・外の入口)を 1 モジュールに置いて 669 行。500 行以下にしたら消す。分けるなら、外の世界の読み出し(relay・base_tracking_ref・release_plugin・git_head・dev_version)と一時レーンの組み立て(helper_spawn・present_approval・helper_task)を別のモジュールへ移す", "lines": 669} +{"path": "plugins/ndf/scripts/lib/parallel_run.py", "name": "", "kind": "lines", "reason": "#2436 で並列の計画の耐久ワークフロー(耐久ステップ・_Run の周回・一時レーンの起動・外の入口)を 1 モジュールに置いて 722 行。500 行以下にしたら消す。分けるなら、外の世界の読み出し(relay・base_tracking_ref・release_plugin・git_head・dev_version)と一時レーンの組み立て(helper_spawn・present_approval・helper_task)を別のモジュールへ移す", "lines": 722} From 7c0600ef727da72e0b4b10e5cc3287bc0473c963 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 13:52:52 +0000 Subject: [PATCH 58/70] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/scripts/lib/sprint=5Floop=5Fselect.py#choose=5Fi?= =?UTF-8?q?ssues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit マイルストーンごとの候補集め(先に選ぶ課題の補完を含む)を milestone_candidates へ、 選ばれたマイルストーンの境界の外の落選行の整形を outside_boundary_rows へ抽出した。 順位順の並べ替えは _rows_by_rank にまとめた。choose_issues にはマイルストーンの順序・ MVV 承認の照合・_select_candidate_rows の呼び出し・最初の選定成功で返す処理を残す。 exclusion_reason の呼び出し回数と順序は変えていない。 Item-Id: I-001 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/sprint_loop_select.py | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/plugins/ndf/scripts/lib/sprint_loop_select.py b/plugins/ndf/scripts/lib/sprint_loop_select.py index 889a0baba..fb9521815 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_select.py +++ b/plugins/ndf/scripts/lib/sprint_loop_select.py @@ -143,6 +143,27 @@ def _select_candidate_rows( return picked, here +def _rows_by_rank(ms: dict) -> list[dict]: + return sorted(ms.get("rows", []), key=lambda r: r["rank"]) + + +def milestone_candidates(ms: dict, rec: dict, first: set) -> list[dict]: + """マイルストーンの候補(切り出しの境界の内)に、境界の外にある先に選ぶ課題を順位の順で足す。""" + cands = candidates_of(ms, rec["max_issues"], pending_issues(rec)) + have = {r["number"] for r in cands} + return cands + [r for r in _rows_by_rank(ms) if r["number"] in first and r["number"] not in have] + + +def outside_boundary_rows(ms: dict, cands: list[dict], title: str) -> list[dict]: + """選ばれたマイルストーンで候補に入らなかった行(切り出しの境界の外)の落選行。""" + cand_nums = {r["number"] for r in cands} + return [ + {"number": r["number"], "milestone": title, "rank": r["rank"], "decision": DROPPED, "reason": "順位(切り出しの境界の外)"} + for r in _rows_by_rank(ms) + if r["number"] not in cand_nums + ] + + def choose_issues(env: Env, rec: dict, rank: dict, lap_n: int, descriptions: dict, lap_created: set = frozenset()) -> dict: """選定(決定 2)。入力は rank の出力・記録・マイルストーンの説明だけ。戻り値は {"milestone", "selection", "candidates"}。選べたマイルストーンが無ければ milestone は None。""" @@ -154,9 +175,7 @@ def choose_issues(env: Env, rec: dict, rank: dict, lap_n: int, descriptions: dic first = first_issues(rec) for title in order: ms = by_title[title] - cands = candidates_of(ms, rec["max_issues"], pending_issues(rec)) - have = {r["number"] for r in cands} - cands += [r for r in sorted(ms.get("rows", []), key=lambda r: r["rank"]) if r["number"] in first and r["number"] not in have] + cands = milestone_candidates(ms, rec, first) total += len(cands) text = sprint_mvv.mvv_sections(descriptions.get(title) or "") unapproved = text is not None and (title, pm.sha256_text(text)) not in approvals @@ -169,12 +188,7 @@ def choose_issues(env: Env, rec: dict, rank: dict, lap_n: int, descriptions: dic ) rows_out += here if picked: - cand_nums = {r["number"] for r in cands} - rest = [r for r in sorted(ms.get("rows", []), key=lambda r: r["rank"]) if r["number"] not in cand_nums] - rows_out += [ - {"number": r["number"], "milestone": title, "rank": r["rank"], "decision": DROPPED, "reason": "順位(切り出しの境界の外)"} - for r in rest - ] + rows_out += outside_boundary_rows(ms, cands, title) return {"milestone": title, "selection": rows_out, "candidates": total, "sprint_mvv": text} return {"milestone": None, "selection": rows_out, "candidates": total, "sprint_mvv": None} From 7b083bc0ffdc8dd3cee18819ecd0b19d298ff437 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 13:54:22 +0000 Subject: [PATCH 59/70] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/lib/parallel=5Fplan.py#load=5Fp?= =?UTF-8?q?lan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上限を外して計画を読む式が parallel_run.py の 4 か所と parallel_lane.py の lane_of に 同じ形で写されていたため、parallel_plan.py に load_valid_plan を足して 1 か所にまとめた。 上限の値は名前付きの _NO_LIMIT で渡す。例外と try/except の範囲は変えていない。 Item-Id: I-002 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_lane.py | 2 +- plugins/ndf/scripts/lib/parallel_plan.py | 9 +++++++++ plugins/ndf/scripts/lib/parallel_run.py | 8 ++++---- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py index ae0ae8c90..c1c458331 100644 --- a/plugins/ndf/scripts/lib/parallel_lane.py +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -55,7 +55,7 @@ def lane_of(main: Path, lane: Optional[str] = None, status=run_status) -> Option return None for name in plans_in(main): try: - plan = pp.ParallelPlan.model_validate(pp.load_plan(main, name), context={"max_plans": 10**6, "max_named": 10**6}) + plan = pp.load_valid_plan(main, name) except (OSError, ValueError): continue hit = plan.lane(lane) diff --git a/plugins/ndf/scripts/lib/parallel_plan.py b/plugins/ndf/scripts/lib/parallel_plan.py index 145200cd1..4d0fa1255 100644 --- a/plugins/ndf/scripts/lib/parallel_plan.py +++ b/plugins/ndf/scripts/lib/parallel_plan.py @@ -270,6 +270,15 @@ def load_plan(main: Path, name: str) -> object: return json.loads((plan_dir(main, name) / "plan.json").read_text(encoding="utf-8")) +# 上限を外して読む(上限は計画を作るときに見る。読むときは書かれた計画をそのまま受ける) +_NO_LIMIT = {"max_plans": 10**6, "max_named": 10**6} + + +def load_valid_plan(main: Path, name: str) -> ParallelPlan: + """`plan.json` を上限を外して検証した計画。例外は `load_plan` と検証のものをそのまま投げる。""" + return ParallelPlan.model_validate(load_plan(main, name), context=_NO_LIMIT) + + def render_md(plan: ParallelPlan, progress: Optional[dict] = None) -> str: """`plan.md`: レーンの表・決まり・(進行があれば)レーンの状態と配布の内訳。""" lines = [f"# 並列の計画 {plan.name}", "", "`plan.json` から描画した。正本は `plan.json` である。", "", "## レーン", ""] diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 55c7f13c6..d39c054ae 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -114,7 +114,7 @@ def step_init(main: str, name: str) -> dict: @durable.step(name="parallel.spawn") def step_spawn(main: str, name: str, lanes: list[str]) -> dict: """`lanes` を起動する。成功(同じ名前の 2 本目の断りも成功とする)は `started`、断りは `refused`(I7)。""" - plan = pp.ParallelPlan.model_validate(pp.load_plan(Path(main), name), context={"max_plans": 10**6, "max_named": 10**6}) + plan = pp.load_valid_plan(Path(main), name) out = {} for n in lanes: ln = plan.lane(n) @@ -159,7 +159,7 @@ def step_held_loops(main: str, name: str, answered: list[str]) -> list[str]: def held_loops(main: Path, name: str, answered: list[str]) -> list[str]: """答えを受けたのに残りを流していない保留の周回を持つ、計画のループのレーンのループ名(異常系 11)。""" try: - plan = pp.ParallelPlan.model_validate(pp.load_plan(main, name), context={"max_plans": 10**6, "max_named": 10**6}) + plan = pp.load_valid_plan(main, name) except (OSError, ValueError): return [] out = [] @@ -189,7 +189,7 @@ def design_held(main: Path, name: str, answered: list[str]) -> list[str]: 答え(`answer-<依頼>`)は持ち主が周回の境で当てる。当てる前に計画を終えると、起こしたレーンが以後の配布の 依頼を送れないため、当てるまで計画を終えない。""" try: - plan = pp.ParallelPlan.model_validate(pp.load_plan(main, name), context={"max_plans": 10**6, "max_named": 10**6}) + plan = pp.load_valid_plan(main, name) except (OSError, ValueError): return [] out = [] @@ -250,7 +250,7 @@ def step_render(main: str, name: str, progress: dict) -> None: def render_progress(main: str, name: str, progress: dict) -> None: try: - plan = pp.ParallelPlan.model_validate(pp.load_plan(Path(main), name), context={"max_plans": 10**6, "max_named": 10**6}) + plan = pp.load_valid_plan(Path(main), name) except (OSError, ValueError): return (pp.plan_dir(Path(main), name) / "plan.md").write_text(pp.render_md(plan, progress), encoding="utf-8") From de531980e43ed4b745ef4d653d15329195ccbd3d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 13:55:56 +0000 Subject: [PATCH 60/70] =?UTF-8?q?Refactor:=20remove=5Fdead=5Fcode=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/release=5Flib/declared=5Fsteps.?= =?UTF-8?q?py#STAGES?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit どこからも読まれていない定数 STAGES を消す。工程の値は _StepShape.stage の Literal が持つ。 Item-Id: I-003 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/release_lib/declared_steps.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/ndf/scripts/release_lib/declared_steps.py b/plugins/ndf/scripts/release_lib/declared_steps.py index 8a46e61a3..22c6a615c 100644 --- a/plugins/ndf/scripts/release_lib/declared_steps.py +++ b/plugins/ndf/scripts/release_lib/declared_steps.py @@ -20,7 +20,6 @@ DECLARATION = ".ndf/release.json" SUPPORTED_VERSIONS = (1,) -STAGES = ("production", "verification", "any") DEFAULT_TIMEOUT = 600 From 2de2d6fb1c44c61332028fbb960fb3d7f6c40876 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 13:57:43 +0000 Subject: [PATCH 61/70] =?UTF-8?q?Refactor:=20introduce=5Fnamed=5Fconstant?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/scripts/lib/parallel=5Frun.py#held?= =?UTF-8?q?=5Floops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit held_loops と design_held が直書きしていた保留の周回の結果("配布待ち"・"質問待ち")と ループの記録の置き場(.ndf/loop/<名前>/loop.json)を、正本の sprint_loop_record の PENDING・LOOP_DIR・RECORD へ置き換えた。import が 1 行増えたため、構造の例外リストの行数を 723 に合わせた。 Item-Id: I-004 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_run.py | 7 ++++--- ...plugins__ndf__scripts__lib__parallel_run.py--lines.json | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index d39c054ae..6d311b5f5 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -26,6 +26,7 @@ import parallel_lane as pl import parallel_plan as pp import parallel_release as pr +from sprint_loop_record import LOOP_DIR, PENDING, RECORD HERE = Path(__file__).resolve().parent.parent RELAY = HERE / "relay.py" @@ -167,11 +168,11 @@ def held_loops(main: Path, name: str, answered: list[str]) -> list[str]: if ln.input.kind != "loop": continue try: - rec = json.loads((Path(main) / ".ndf" / "loop" / ln.input.loop / "loop.json").read_text(encoding="utf-8")) + rec = json.loads((Path(main) / LOOP_DIR / ln.input.loop / RECORD).read_text(encoding="utf-8")) except (OSError, ValueError): continue for lap in rec.get("laps") or []: - part = (lap.get("release") or lap.get("question") or {}) if lap.get("outcome") in ("配布待ち", "質問待ち") else {} + part = (lap.get("release") or lap.get("question") or {}) if lap.get("outcome") in PENDING else {} if part.get("request") in answered: out.append(ln.input.loop) break @@ -197,7 +198,7 @@ def design_held(main: Path, name: str, answered: list[str]) -> list[str]: if ln.input.kind != "loop": continue try: - rec = json.loads((Path(main) / ".ndf" / "loop" / ln.input.loop / "loop.json").read_text(encoding="utf-8")) + rec = json.loads((Path(main) / LOOP_DIR / ln.input.loop / RECORD).read_text(encoding="utf-8")) except (OSError, ValueError): continue if any(w.get("request") in answered and not w.get("answer") for w in rec.get("waiting") or []): diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json index a7bb42f51..3459fa0b2 100644 --- a/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/scripts/lib/parallel_run.py", "name": "", "kind": "lines", "reason": "#2436 で並列の計画の耐久ワークフロー(耐久ステップ・_Run の周回・一時レーンの起動・外の入口)を 1 モジュールに置いて 722 行。500 行以下にしたら消す。分けるなら、外の世界の読み出し(relay・base_tracking_ref・release_plugin・git_head・dev_version)と一時レーンの組み立て(helper_spawn・present_approval・helper_task)を別のモジュールへ移す", "lines": 722} +{"path": "plugins/ndf/scripts/lib/parallel_run.py", "name": "", "kind": "lines", "reason": "#2436 で並列の計画の耐久ワークフロー(耐久ステップ・_Run の周回・一時レーンの起動・外の入口)を 1 モジュールに置いて 723 行。500 行以下にしたら消す。分けるなら、外の世界の読み出し(relay・base_tracking_ref・release_plugin・git_head・dev_version)と一時レーンの組み立て(helper_spawn・present_approval・helper_task)を別のモジュールへ移す", "lines": 723} From e54dbdcc78565eb5d6a80c7f0abcdf95e38277be Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 14:01:07 +0000 Subject: [PATCH 62/70] =?UTF-8?q?Fix:=20=E6=A4=9C=E8=A8=BC=E3=81=AE?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=82=92=E7=9B=B4=E3=81=99=20=E2=80=94=20plu?= =?UTF-8?q?gins/ndf/scripts/lib/parallel=5Frun.py#held=5Floops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parallel_run が書き手の sprint_loop_record を読み込んだため、契約の検査が parallel.py の入口へ loop.json・laps.md の書きと NDF_UPKEEP_STATE_DIR の読みを辿って落ちていた。 LOOP_DIR と保留の周回の結果(RELEASE_PENDING・QUESTION_PENDING・PENDING)を副作用の無い 新しいモジュール sprint_loop_names へ移し、sprint_loop_record はそこから読み込んで公開し直す。 parallel_run は sprint_loop_names だけを読み込む。RECORD は契約の検査がモジュールの中の定数しか 解決しないため書き手の sprint_loop_record に残し、parallel_run の読みは元のファイル名のままにした。 Item-Id: I-004 Impl-Runtime: claude Impl-Model: default --- plugins/ndf/scripts/lib/parallel_run.py | 6 +++--- plugins/ndf/scripts/lib/sprint_loop_names.py | 15 +++++++++++++++ plugins/ndf/scripts/lib/sprint_loop_record.py | 4 +--- 3 files changed, 19 insertions(+), 6 deletions(-) create mode 100644 plugins/ndf/scripts/lib/sprint_loop_names.py diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 6d311b5f5..9a94063e8 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -26,7 +26,7 @@ import parallel_lane as pl import parallel_plan as pp import parallel_release as pr -from sprint_loop_record import LOOP_DIR, PENDING, RECORD +from sprint_loop_names import LOOP_DIR, PENDING HERE = Path(__file__).resolve().parent.parent RELAY = HERE / "relay.py" @@ -168,7 +168,7 @@ def held_loops(main: Path, name: str, answered: list[str]) -> list[str]: if ln.input.kind != "loop": continue try: - rec = json.loads((Path(main) / LOOP_DIR / ln.input.loop / RECORD).read_text(encoding="utf-8")) + rec = json.loads((Path(main) / LOOP_DIR / ln.input.loop / "loop.json").read_text(encoding="utf-8")) except (OSError, ValueError): continue for lap in rec.get("laps") or []: @@ -198,7 +198,7 @@ def design_held(main: Path, name: str, answered: list[str]) -> list[str]: if ln.input.kind != "loop": continue try: - rec = json.loads((Path(main) / LOOP_DIR / ln.input.loop / RECORD).read_text(encoding="utf-8")) + rec = json.loads((Path(main) / LOOP_DIR / ln.input.loop / "loop.json").read_text(encoding="utf-8")) except (OSError, ValueError): continue if any(w.get("request") in answered and not w.get("answer") for w in rec.get("waiting") or []): diff --git a/plugins/ndf/scripts/lib/sprint_loop_names.py b/plugins/ndf/scripts/lib/sprint_loop_names.py new file mode 100644 index 000000000..c48c50f87 --- /dev/null +++ b/plugins/ndf/scripts/lib/sprint_loop_names.py @@ -0,0 +1,15 @@ +"""ループの記録の置き場と保留の周回の結果の名前(読み込みに副作用の無い定数だけ)。 + +記録のファイル名 RECORD は書き手の sprint_loop_record に置く(契約の検査はモジュールの中の定数だけを解決する)。 + +sprint_loop_record が正本として再び公開する。parallel_run のように記録を読むだけの側は、 +書き手の sprint_loop_record を読み込まずにここから取る(読み込むと、入口の契約に書きの受け渡しが加わる)。 +""" + +from __future__ import annotations + +from pathlib import Path + +LOOP_DIR = Path(".ndf") / "loop" +RELEASE_PENDING, QUESTION_PENDING = "配布待ち", "質問待ち" # 答えを待たずに締めた保留の周回(#2436 の受け入れ条件 6a・6d) +PENDING = (RELEASE_PENDING, QUESTION_PENDING) diff --git a/plugins/ndf/scripts/lib/sprint_loop_record.py b/plugins/ndf/scripts/lib/sprint_loop_record.py index 456581654..e29eb1879 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_record.py +++ b/plugins/ndf/scripts/lib/sprint_loop_record.py @@ -37,6 +37,7 @@ import project_mvv as pm import repo as repo_lib import step_result +from sprint_loop_names import LOOP_DIR, PENDING, QUESTION_PENDING, RELEASE_PENDING # noqa: F401 -- 正本として再び公開する TOOL = "sprint-state" @@ -45,7 +46,6 @@ SUPERVISE = SCRIPTS / "supervise.py" UPKEEP = SCRIPTS.parent / "skills" / "backlog-refinement" / "scripts" / "upkeep.py" HANDOFF_TEMPLATE = SCRIPTS / "data" / "handoff-template.md" -LOOP_DIR = Path(".ndf") / "loop" HANDOFF_DIR = Path(".ndf") / "handoff" NEXT_STEP_SECTION = "次にやること" # 引継ぎ文書の節(loop start が in_lap のときに指す) RECORD = "loop.json" @@ -58,8 +58,6 @@ EXIT_STOPPED = step_result.EXIT_PRECONDITION # 3: 止まる条件に当たった(失敗ではない) IN_PROGRESS, DONE, FAILED, RETURNED = "進行中", "完了", "失敗", "人へ戻した" -RELEASE_PENDING, QUESTION_PENDING = "配布待ち", "質問待ち" # 答えを待たずに締めた保留の周回(#2436 の受け入れ条件 6a・6d) -PENDING = (RELEASE_PENDING, QUESTION_PENDING) RELEASE_PENDING_MAX = 2 # 保留の周回の上限の既定(.ndf/pace.json の loop.release_pending_max) SELECTED, DROPPED = "選んだ", "外した" BOUNDARY_CONDITIONS = ("user_stop", "mvv_changed", "same_failure", "budget_laps", "budget_usd", "budget_hours", "waiting_only") From f7b9474c88d977e360a443bb96520e7d0a123080 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 13:12:25 +0000 Subject: [PATCH 63/70] =?UTF-8?q?Fix:=20=E4=BE=9D=E9=A0=BC=E3=81=AE?= =?UTF-8?q?=E5=85=A8=20PR=20=E3=82=92=E5=87=BA=E3=81=9B=E3=81=9F=E9=85=8D?= =?UTF-8?q?=E5=B8=83=E3=81=AE=E7=AF=84=E5=9B=B2=E3=81=AE=E5=92=8C=E3=81=A7?= =?UTF-8?q?=E7=85=A7=E3=82=89=E3=81=99=E3=83=BB1=20=E3=82=B9=E3=83=97?= =?UTF-8?q?=E3=83=AA=E3=83=B3=E3=83=88=E3=81=AE=E3=83=AC=E3=83=BC=E3=83=B3?= =?UTF-8?q?=E3=81=8C=E5=95=8F=E3=81=84=E3=81=AE=E7=AD=94=E3=81=88=E3=82=92?= =?UTF-8?q?=E5=BD=93=E3=81=A6=E3=82=8B=E3=81=BE=E3=81=A7=E8=A8=88=E7=94=BB?= =?UTF-8?q?=E3=82=92=E7=B5=82=E3=81=88=E3=81=AA=E3=81=84=EF=BC=88#2436=20?= =?UTF-8?q?=E3=81=AE=20cross-review=20=E6=9C=80=E7=B5=82=E3=82=B9=E3=82=A4?= =?UTF-8?q?=E3=83=BC=E3=83=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_release.py | 10 ++-- plugins/ndf/scripts/lib/parallel_run.py | 51 ++++++++----------- .../ndf/scripts/tests/test_parallel_plan.py | 2 + .../ndf/scripts/tests/test_parallel_run.py | 9 ++++ 4 files changed, 39 insertions(+), 33 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_release.py b/plugins/ndf/scripts/lib/parallel_release.py index e9f6b55b0..ecc106bc7 100644 --- a/plugins/ndf/scripts/lib/parallel_release.py +++ b/plugins/ndf/scripts/lib/parallel_release.py @@ -51,12 +51,14 @@ def map_lanes(plan: pp.ParallelPlan, prs: list[int], closes: dict[int, list[int] return {"lanes": lanes, "orphans": sorted(in_range - claimed)} -def answerable(pending: Iterable[dict], prs: Iterable[int]) -> list[str]: - """依頼の Pull Request がすべて範囲 `prs` に入る依頼の ID(その配布の答えを受ける。I9・I15)。 +def answerable(pending: Iterable[dict], prs: Iterable[int], done: Iterable[int] = ()) -> list[str]: + """範囲 `prs` に Pull Request を持ち、残りもすべて出せた配布の範囲 `done` に入る依頼の ID(その配布の答えを受ける。I9・I15)。 - 一部だけが入る依頼は答えず、次の配布へ回す(残りを本番へ出さずに配布済みと答えない)。""" + 先の Pull Request が前の配布で本番に出た後に届いた依頼も、今回の範囲と合わせて全部が入れば答える。 + 一部が入らない依頼は答えず、次の配布へ回す(残りを本番へ出さずに配布済みと答えない)。""" got = set(prs) - return [r["id"] for r in pending if (want := set(r.get("prs") or [])) and want <= got] + have = got | set(done) + return [r["id"] for r in pending if (want := set(r.get("prs") or [])) and want <= have and want & got] def breakdown_md(mapped: dict, added: Optional[dict] = None) -> str: diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 9a94063e8..46390b1f3 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -159,23 +159,22 @@ def step_held_loops(main: str, name: str, answered: list[str]) -> list[str]: def held_loops(main: Path, name: str, answered: list[str]) -> list[str]: """答えを受けたのに残りを流していない保留の周回を持つ、計画のループのレーンのループ名(異常系 11)。""" + asked = lambda lap: (lap.get("release") or lap.get("question") or {}).get("request") if lap.get("outcome") in PENDING else None # noqa: E731 + return [ln.input.loop for ln, rec in _loop_records(main, name) if any(asked(lap) in answered for lap in rec.get("laps") or [])] + + +def _loop_records(main: Path, name: str) -> list[tuple]: + """計画のループのレーンと、読めたループの記録(loop.json)の組。計画を読めなければ空。""" try: plan = pp.load_valid_plan(main, name) except (OSError, ValueError): return [] out = [] - for ln in plan.lanes: - if ln.input.kind != "loop": - continue + for ln in (ln for ln in plan.lanes if ln.input.kind == "loop"): try: - rec = json.loads((Path(main) / LOOP_DIR / ln.input.loop / "loop.json").read_text(encoding="utf-8")) + out.append((ln, json.loads((Path(main) / LOOP_DIR / ln.input.loop / "loop.json").read_text(encoding="utf-8")))) except (OSError, ValueError): continue - for lap in rec.get("laps") or []: - part = (lap.get("release") or lap.get("question") or {}) if lap.get("outcome") in PENDING else {} - if part.get("request") in answered: - out.append(ln.input.loop) - break return out @@ -189,21 +188,8 @@ def design_held(main: Path, name: str, answered: list[str]) -> list[str]: 答え(`answer-<依頼>`)は持ち主が周回の境で当てる。当てる前に計画を終えると、起こしたレーンが以後の配布の 依頼を送れないため、当てるまで計画を終えない。""" - try: - plan = pp.load_valid_plan(main, name) - except (OSError, ValueError): - return [] - out = [] - for ln in plan.lanes: - if ln.input.kind != "loop": - continue - try: - rec = json.loads((Path(main) / LOOP_DIR / ln.input.loop / "loop.json").read_text(encoding="utf-8")) - except (OSError, ValueError): - continue - if any(w.get("request") in answered and not w.get("answer") for w in rec.get("waiting") or []): - out.append(ln.name) - return out + held = lambda rec: any(w.get("request") in answered and not w.get("answer") for w in rec.get("waiting") or []) # noqa: E731 + return [ln.name for ln, rec in _loop_records(main, name) if held(rec)] @durable.step(name="parallel.build") @@ -283,6 +269,8 @@ def __init__(self, main: str, name: str, lanes: list[dict]): self.helper_asked: Optional[int] = None self.held_spawned: list[str] = [] self.wake: list[str] = [] + self.sprint = {ln["name"] for ln in lanes if (ln.get("input") or {}).get("kind") == "sprint"} + self.revive: list[str] = [] # 問いへ答えた後、まだ動き出していない止まった 1 スプリントのレーン(6f) def bump(self) -> int: self.seq += 1 @@ -356,6 +344,8 @@ def reply(self, msg: dict) -> None: durable.set_event(f"answer-{each}", {k: msg.get(k) for k in ("result", "note", "choice", "stamp", "at")}) self.questions = [q for q in self.questions if q.get("id") != rid] self.publish_questions() + owners = {q.get("lane"), *(m.get("lane") for m in q.get("merged", []))} & self.sprint & self.stopped() + self.revive = sorted({*self.revive, *owners}) # 止まった持ち主を起こして答えを当てるまで計画を終えない self.apply_own(q, list(msg.get("choice") or [])) def apply_own(self, q: dict, choice: list[str]) -> None: @@ -386,11 +376,12 @@ def answer(self, rid: str, result: str, version: Optional[str], n: Optional[int] # -- 配布 -- def answer_from_ranges(self) -> None: - """出した配布の範囲で答えられる依頼に答えて外す(I15)。""" + """出した配布の範囲で答えられる依頼に答えて外す(I15)。依頼の全 PR がそれまでに出せた範囲の和に入れば答える。""" + done = {n for r in self.releases if r.get("state") == "released" for n in r.get("prs") or []} for rel in self.releases: if rel.get("state") != "released": continue - hit = set(pr.answerable(self.pending, rel.get("prs") or [])) + hit = set(pr.answerable(self.pending, rel.get("prs") or [], done)) for r in [r for r in self.pending if r["id"] in hit]: self.answer(r["id"], "released", rel.get("version"), rel["n"]) self.pending = [r for r in self.pending if r["id"] not in hit] @@ -462,7 +453,8 @@ def settle(self, rel: dict, msg: dict) -> None: if not rel.get("prs"): got = step_presented(rel["dirs"][-1]) rel["prs"] = got.get("prs") or [] - hit = set(pr.answerable(self.pending, rel["prs"])) + done = {n for r in self.releases if r is not rel and r.get("state") == "released" for n in r.get("prs") or []} + hit = set(pr.answerable(self.pending, rel["prs"], done)) # 前の配布で出た PR と合わせて全部が入る依頼に答える answered = [r for r in self.pending if r["id"] in hit] if result == "released": rel.update(state="released", version=msg.get("version") or rel.get("version")) @@ -571,8 +563,8 @@ def parallel_workflow(main: str, name: str) -> dict: def _finish_or_hold(run: _Run, main: str, name: str) -> Optional[dict]: """全レーンが止まった後: 終えられれば要約を返す。答えを当てていない設計の承認か、保留の周回があれば None。""" - designs = step_design_held(main, name, sorted(run.answered)) - if designs: # 持ち主が設計の承認の答えを当てるまで終えない。起こす 1 行を知らせへ添え続ける(6f) + designs = sorted({*step_design_held(main, name, sorted(run.answered)), *(set(run.revive) & run.stopped())}) + if designs: # 持ち主が答え(設計の承認・1 スプリントのレーンの問い)を当てるまで終えない。起こす 1 行を知らせへ添え続ける(6f) if set(designs) - set(run.wake): run.wake = sorted({*run.wake, *designs}) run.pause("notice", lanes=[], attach=[]) @@ -639,6 +631,7 @@ def _observe_run(run: _Run, main: str) -> None: st = "stopped" if st and st != run.lanes[n].get("state"): run.lanes[n]["state"] = st + run.revive = [x for x in run.revive if x != n or st == "stopped"] # 起こしたレーンが動き出したら待ちを外す if st == "awaiting_approval": noticed.append(n) if noticed: diff --git a/plugins/ndf/scripts/tests/test_parallel_plan.py b/plugins/ndf/scripts/tests/test_parallel_plan.py index 6940a891a..ad4358fb1 100644 --- a/plugins/ndf/scripts/tests/test_parallel_plan.py +++ b/plugins/ndf/scripts/tests/test_parallel_plan.py @@ -196,6 +196,8 @@ def test_answerable_and_moved(): assert prl.answerable(pending, [1, 2]) == ["a"] # 一部だけが範囲に入る依頼は答えない(残りを出さずに配布済みと答えない) assert prl.answerable([{"id": "c", "prs": [1, 3]}, {"id": "d", "prs": []}], [1, 2]) == [] + # 先の PR が前の配布で出た依頼は、今回の範囲と合わせて全部が入れば答える。前の範囲だけの依頼はこの配布で答えない + assert prl.answerable([{"id": "e", "prs": [1, 3]}, {"id": "f", "prs": [3]}], [1, 2], done=[3]) == ["e"] assert prl.moved("abc", "abc") is False and prl.moved("abc", "def") is True diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index 8b7d113a7..2bcecf993 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -559,6 +559,9 @@ def test_the_plan_waits_until_the_owner_applies_a_design_answer(env, monkeypatch assert ev["wake"] == [f"python3 {prun.RELAY} restart la"] assert durable.status(pl.workflow_id("p1")) in pl.RUNNING applied["v"] = True + states.write_text(json.dumps({"la": "running", "lb": "stopped"})) # 起こした la が動き出し、答えを当てて止まる + time.sleep(1.0) # 動き出した la を観測させる + states.write_text(json.dumps({"la": "stopped", "lb": "stopped"})) assert durable.output_of(pl.workflow_id("p1"))["status"] == "finished" @@ -625,6 +628,12 @@ def test_the_plan_does_not_finish_while_a_question_is_unanswered(env, tmp_path): time.sleep(1.0) assert durable.status(pl.workflow_id("p1")) in pl.RUNNING pl.send_answer(main, "p1", rid, "answered", choice=["済んだ"]) + ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("wake") and e) # 止まった持ち主が答えを当てるまで終えない + assert ev["wake"] == [f"python3 {prun.RELAY} restart la"] + assert durable.status(pl.workflow_id("p1")) in pl.RUNNING + states.write_text(json.dumps({"la": "running", "lb": "stopped"})) + time.sleep(1.0) # 動き出した la を観測させる + states.write_text(json.dumps({"la": "stopped", "lb": "stopped"})) assert durable.output_of(pl.workflow_id("p1"))["status"] == "finished" From c85531a43473ca028eedf2f3f83c5343621c914d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 14:49:11 +0000 Subject: [PATCH 64/70] =?UTF-8?q?Fix:=20ndf-next=20=E3=82=92=E5=87=BA?= =?UTF-8?q?=E3=81=95=E3=81=9A=E3=81=AB=E7=B5=82=E3=81=88=E3=82=8B=E3=83=AC?= =?UTF-8?q?=E3=83=BC=E3=83=B3=E3=81=8C=E8=87=AA=E5=88=86=E3=81=AE=E3=83=A9?= =?UTF-8?q?=E3=83=83=E3=83=91=E3=83=BC=E3=82=92=E6=AD=A2=E3=82=81=E3=82=8B?= =?UTF-8?q?=EF=BC=88#2436=20=E3=81=AE=20cross-review=20=E3=81=AE=E4=BF=9D?= =?UTF-8?q?=E7=95=99=202=20=E4=BB=B6=E3=80=81=E5=88=A4=E6=96=AD=20fca6fe46?= =?UTF-8?q?4619=20=E3=81=AE=20(a)=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ndf-next を出さずにセッションを終えても対話の claude が入力待ちで残り、ラッパーが running のまま止まらなかった。 終わり方が 3 か所とも「最後に relay.py stop で自分のラッパーを止める」になるようにした。 - 一時レーン: 起動の 1 行(helper_spawn)の「終わり」に relay.py stop <一時レーンの名前> を入れた(配布と保留の周回の両方) - 問いで保留した 1 スプリントのレーン・一時レーン: 問いの hook が返す保留の手順の最後に停止の 1 行を足した - 待ちだけで止まったループのレーン: 止まった結果に metrics.stop_self を付けた。並列の計画のレーンのときだけ返し、 利用者の端末のラッパーは止めない - 答えの後に持ち主を起こす要否(parallel.py の _owner_restart)を、ラッパーの状態でなくレーンの側の待ちの記録で決める (ループは waiting_only で止まった記録、1 スプリントのレーンは引継ぎ文書の依頼の id、一時レーンはラッパーの状態) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KYHP826qdiDahV9gSeoGPK --- plugins/ndf/scripts/hook_lib/ask_guard.py | 6 ++- plugins/ndf/scripts/lib/parallel_files.py | 14 ++++++ plugins/ndf/scripts/lib/parallel_lane.py | 24 ++++++++++ plugins/ndf/scripts/lib/parallel_run.py | 7 ++- plugins/ndf/scripts/lib/sprint_loop_record.py | 11 ++--- plugins/ndf/scripts/parallel.py | 8 ++-- plugins/ndf/scripts/tests/test_ask_guard.py | 10 ++++ .../ndf/scripts/tests/test_parallel_run.py | 48 +++++++++++++++++++ plugins/ndf/scripts/tests/test_sprint_loop.py | 26 ++++++++++ .../development-workflow/references/asking.md | 2 +- .../development-workflow/references/loop.md | 4 ++ .../references/parallel.md | 8 ++-- 12 files changed, 150 insertions(+), 18 deletions(-) diff --git a/plugins/ndf/scripts/hook_lib/ask_guard.py b/plugins/ndf/scripts/hook_lib/ask_guard.py index 82ce84aa6..7074b71dc 100644 --- a/plugins/ndf/scripts/hook_lib/ask_guard.py +++ b/plugins/ndf/scripts/hook_lib/ask_guard.py @@ -105,12 +105,16 @@ def run_request_ask(main: Path, plan: str, lane: str, payload: dict) -> tuple[in def hold_text(rid: str, plan: str) -> str: """オーガナイザーへ送った問いの理由と保留の手順(正本: asking.md の「並列の計画のレーン」)。""" + import parallel_files + + stop = parallel_files.stop_self() or f'python3 {parallel_files.RELAY} stop "$NDF_RELAY_NAME"' loop = f'python3 "{Path(__file__).resolve().parent.parent / "sprint-state.py"}" loop hold-question <ループ> --ref {rid} --plan <止まったプラン> --step <続きのステップ> --parallel {plan}' return "\n".join( [ f"ndf: 並列の計画 {plan} のレーンの問いをオーガナイザーへ送った(依頼 {rid})。この作業を保留し、別の作業へ進め。答えは周回の境で届く。", f"開発ループのレーン: {loop} を打ち、周回を質問待ちで締めて次の周回へ進む。", - f"1 スプリントのレーン・一時レーン: 引継ぎ文書の「次にやること」に続きのコマンドと依頼 {rid} を書き、ndf-next を出さずにセッションを終える(オーガナイザーが起こす)。", + f"1 スプリントのレーン・一時レーン: 引継ぎ文書の「次にやること」に続きのコマンドと依頼 {rid} を書き、ndf-next を出さずに、" + f"最後に {stop} を打って自分のラッパーを止めてセッションを終える(オーガナイザーが起こす)。", f"正本: {ASKING_MD}", ] ) diff --git a/plugins/ndf/scripts/lib/parallel_files.py b/plugins/ndf/scripts/lib/parallel_files.py index 1e0e52593..75ed49d4e 100644 --- a/plugins/ndf/scripts/lib/parallel_files.py +++ b/plugins/ndf/scripts/lib/parallel_files.py @@ -7,11 +7,25 @@ from __future__ import annotations import json +import os +import shlex from pathlib import Path from typing import Optional PARALLEL_DIR = Path(".ndf") / "parallel" ROLE_FILE = "role.json" # 統括役の作業ディレクトリの役割のファイル(relay_lib.hub.ROLE_FILE と同じ公開の形。統括役を import しない) +RELAY = Path(__file__).resolve().parent.parent / "relay.py" + + +def stop_self(main: Optional[Path] = None, env=None) -> Optional[str]: + """セッションの最後に自分のラッパーを止める 1 行(判断 fca6fe464619 の (a))。`ndf-next` を出さずに終えても対話の claude が + 入力待ちで残り、ラッパーが止まらないためである。ラッパーの外(`NDF_RELAY_NAME` が無い)では None(打たない)。 + `main` を渡すと、並列の計画のレーンか一時レーンのときだけ返す(利用者の端末のラッパーを止めない)。""" + env = os.environ if env is None else env + name = env.get("NDF_RELAY_NAME") + if not name or (main is not None and lane_name_in_plans(Path(main), name, env.get("NDF_RELAY_DIR")) is None): + return None + return f'python3 {shlex.quote(str(RELAY))} stop "$NDF_RELAY_NAME"' def plans_in(main: Path) -> list[str]: diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py index c1c458331..4fb035d4d 100644 --- a/plugins/ndf/scripts/lib/parallel_lane.py +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -64,6 +64,30 @@ def lane_of(main: Path, lane: Optional[str] = None, status=run_status) -> Option return None +def lane_waits(main: Path, name: str, lane: str, rid: str, relay_ls) -> bool: + """答えの後に持ち主のレーンを起こすかを、レーンの側の待ちの記録で決める(6f)。ラッパーの状態では決めない(止め方の + 食い違いで running が残っても起こし損ねないため)。ループのレーンは記録が `waiting_only` で止まっている、1 スプリントの + レーンは引継ぎ文書に依頼の id がある(保留の手順が書く)。記録を持たない一時レーンは、ラッパーが止まっているか + (`relay_ls()` の統一 JSON レポートで running / awaiting_approval でない)で決める(保留の手順の最後に自分で止める)。""" + from sprint_loop_names import LOOP_DIR + + try: + hit = pp.load_valid_plan(main, name).lane(lane) + except (OSError, ValueError): + hit = None + if hit is None: + code, got = relay_ls() + state = next((i.get("state") for i in got.get("items") or [] if i.get("name") == lane), None) if code == 0 else None + return state not in ("running", "awaiting_approval") + try: + if isinstance(hit.input, pp.LoopInput): + rec = json.loads((Path(main) / LOOP_DIR / hit.input.loop / "loop.json").read_text(encoding="utf-8")) + return rec.get("status") == "stopped" and (rec.get("stop") or {}).get("condition") == "waiting_only" + return rid in (Path(main) / ".ndf" / "handoff" / f"{hit.handoff_name()}.md").read_text(encoding="utf-8") + except (OSError, ValueError, AttributeError): + return False + + def lane_plans(main: Path, lane: Optional[str] = None) -> Optional[int]: """レーンなら、そのレーンの同時に流すプランの本数。レーンでなければ None(`queue --max` の既定)。""" got = lane_of(main, lane) diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 46390b1f3..e1bd71bf2 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -486,8 +486,11 @@ def settle(self, rel: dict, msg: dict) -> None: def helper_spawn(name: str, n: int, k: int, pending: Optional[str] = None) -> str: """一時レーンを起こす 1 行(`metrics.spawn`)。pending はループ名で、保留の周回の残りを流す一時レーン(異常系 11)。""" what = f"pending {pending}" if pending else f"{n} {k}" - first = f"/goal /ndf:development-workflow --parallel-helper {name} {what} -- 終わり: [ndf-parallel] helper-done {name} の行を出したとき" - return f"python3 {RELAY} spawn --name {pr.helper_name(name, n)} --role helper --plan {name} --json -- '{first}'" + helper = pr.helper_name(name, n) + # 終わりに自分のラッパーを止める(判断 fca6fe464619 の (a))。止めないと対話の claude が入力待ちで残り、続きの一時レーンが断られる + end = f"[ndf-parallel] helper-done {name} の行を出し、python3 {RELAY} stop {helper} で自分のラッパーを止めたとき" + first = f"/goal /ndf:development-workflow --parallel-helper {name} {what} -- 終わり: {end}" + return f"python3 {RELAY} spawn --name {helper} --role helper --plan {name} --json -- '{first}'" def present_approval(d: Path) -> str: diff --git a/plugins/ndf/scripts/lib/sprint_loop_record.py b/plugins/ndf/scripts/lib/sprint_loop_record.py index e29eb1879..6cf3b2482 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_record.py +++ b/plugins/ndf/scripts/lib/sprint_loop_record.py @@ -34,6 +34,7 @@ import handoff_doc import jsonio import locks +import parallel_files import project_mvv as pm import repo as repo_lib import step_result @@ -382,13 +383,9 @@ def stopped_result(env: Env, rec: dict, summary: str, extra_metrics: dict | None resume = resume_command(rec) update_handoff(env, rec, f"止まった({stop['condition']})。理由が解けたら再開のコマンドで続ける", resume, resume) items = [{"condition": stop["condition"], "values": stop["values"], "resume": resume}] - metrics = { - "status": "stopped", - "stop": stop, - "stop_line": stop_line(rec, stop["condition"]), - "consumed": consumed(rec), - **(extra_metrics or {}), - } + metrics = {"status": "stopped", "stop": stop, "stop_line": stop_line(rec, stop["condition"]), "consumed": consumed(rec)} + # 待ちだけで止まったレーンは、自分のラッパーを止める 1 行(stop_self)を打って終える(判断 fca6fe464619 の (a)) + metrics.update(extra_metrics or {}, stop_self=parallel_files.stop_self(env.main_dir()) if stop["condition"] == "waiting_only" else None) return {**step_result.result(TOOL, "stopped", summary, items, metrics), "exit": EXIT_STOPPED} diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index 407d8c600..056ec7ca6 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -365,7 +365,8 @@ def cmd_request_gate2(a) -> None: def _owner_restart(main: Path, name: str, rid: str, choice: list[str] | None = None) -> str | None: - """答えの後の 1 行。自分で立てた問い(stall・helper・spawn)は選んだ答えの commands、ほかは止まった持ち主の restart。""" + """答えの後の 1 行。自分で立てた問い(stall・helper・spawn)は選んだ答えの commands、ほかは答えを待って止まった持ち主の + restart。起こす要否はレーンの側の待ちの記録で決める(`parallel_lane.lane_waits`)。""" import durable import parallel_run as pr_run @@ -373,9 +374,8 @@ def _owner_restart(main: Path, name: str, rid: str, choice: list[str] | None = N q = next((q for q in items if q.get("id") == rid), {}) if q.get("kind") in ("stall", "helper", "spawn") or not (lane := q.get("lane")): return next((q["commands"][c] for c in choice or [] if (q.get("commands") or {}).get(c)), None) - code, got = pr_run.relay(main, "ls", "--json") - state = next((i.get("state") for i in got.get("items") or [] if i.get("name") == lane), None) if code == 0 else None - return None if state in ("running", "awaiting_approval") else f"python3 {pr_run.RELAY} restart {lane}" + waits = pl.lane_waits(main, name, lane, rid, lambda: pr_run.relay(main, "ls", "--json")) + return f"python3 {pr_run.RELAY} restart {lane}" if waits else None def cmd_answer(a) -> None: diff --git a/plugins/ndf/scripts/tests/test_ask_guard.py b/plugins/ndf/scripts/tests/test_ask_guard.py index c2de49bfd..8b3eea9e5 100644 --- a/plugins/ndf/scripts/tests/test_ask_guard.py +++ b/plugins/ndf/scripts/tests/test_ask_guard.py @@ -353,6 +353,16 @@ def test_a_lane_question_is_sent_to_the_organizer_and_denied(proj, monkeypatch, assert (plan, lane) == ("p1", "la") and sent["tool_input"]["metadata"]["source"] == "ndf-ask:cost" and sent["ledger_id"] +def test_the_hold_steps_end_by_stopping_the_own_wrapper(proj, monkeypatch, forwarded): + """保留の手順は自分のラッパーを止めて終える(判断 fca6fe464619 の (a))。ndf-next を出さずに終えても対話の claude が残るためである。""" + import parallel_files + + lane_plan(proj) + monkeypatch.setenv("NDF_RELAY_NAME", "la") + out = json.dumps(decide(proj, "ndf-ask:cost"), ensure_ascii=False) + assert parallel_files.stop_self() and json.dumps(parallel_files.stop_self(), ensure_ascii=False)[1:-1] in out + + def test_a_helper_question_is_sent_by_the_role_file(proj, monkeypatch, forwarded, tmp_path): lane_plan(proj) relay = tmp_path / "relay" diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index 2bcecf993..647b53519 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -664,3 +664,51 @@ def test_answer_to_an_own_question_returns_the_chosen_command(monkeypatch, tmp_p monkeypatch.setattr(durable, "event_from", lambda *_a, **_k: {"items": [item]}) assert cli._owner_restart(tmp_path, "p1", "h1", ["続きから起こし直す"]) == "SPAWN" assert cli._owner_restart(tmp_path, "p1", "h1", ["止める"]) is None + + +def test_owner_is_woken_by_the_lane_side_wait_record(monkeypatch, tmp_path): + """持ち主を起こす要否はラッパーの状態でなくレーンの側の待ちの記録で決める。ループは記録が waiting_only で止まっている、 + 1 スプリントのレーンは引継ぎ文書に依頼の id がある。""" + import importlib.util + + spec = importlib.util.spec_from_file_location("parallel_cli", Path(prun.__file__).resolve().parents[1] / "parallel.py") + cli = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cli) + data = { + "version": 1, + "name": "p1", + "rules": [], + "lanes": [ + {"name": "la", "input": {"kind": "loop", "loop": "l1", "args": ["--milestones", "01 a"]}, "plans": 1, "touches": ["a/"]}, + {"name": "lb", "input": {"kind": "sprint", "issues": [7]}, "plans": 1, "touches": ["b/"]}, + ], + } + pp.plan_dir(tmp_path, "p1").mkdir(parents=True) + (pp.plan_dir(tmp_path, "p1") / "plan.json").write_text(json.dumps(data)) + items = [{"id": "qa", "kind": "ask", "lane": "la"}, {"id": "qb", "kind": "ask", "lane": "lb"}] + monkeypatch.setattr(durable, "event_from", lambda *_a, **_k: {"items": items}) + monkeypatch.setattr( + prun, "relay", lambda *_a, **_k: (0, {"items": [{"name": "la", "state": "running"}, {"name": "lb", "state": "running"}]}) + ) + loop = tmp_path / ".ndf" / "loop" / "l1" + loop.mkdir(parents=True) + handoff = tmp_path / ".ndf" / "handoff" + handoff.mkdir(parents=True) + assert cli._owner_restart(tmp_path, "p1", "qa") is None and cli._owner_restart(tmp_path, "p1", "qb") is None + (loop / "loop.json").write_text(json.dumps({"status": "running"})) + (handoff / "issue-7.md").write_text("## 次にやること\n- 別の作業\n") + assert cli._owner_restart(tmp_path, "p1", "qa") is None and cli._owner_restart(tmp_path, "p1", "qb") is None + (loop / "loop.json").write_text(json.dumps({"status": "stopped", "stop": {"condition": "waiting_only"}})) + (handoff / "issue-7.md").write_text("## 次にやること\n- 依頼 qb の答えを読んで続ける\n") + assert cli._owner_restart(tmp_path, "p1", "qa") == f"python3 {prun.RELAY} restart la" + assert cli._owner_restart(tmp_path, "p1", "qb") == f"python3 {prun.RELAY} restart lb" + items.append({"id": "qh", "kind": "ask", "lane": "p1-rel1"}) # 記録を持たない一時レーンはラッパーが止まっているかで決める + assert cli._owner_restart(tmp_path, "p1", "qh") == f"python3 {prun.RELAY} restart p1-rel1" + + +def test_a_helper_ends_by_stopping_its_own_wrapper(): + """一時レーンは helper-done の後に自分のラッパーを止めて終える(判断 fca6fe464619 の (a))。止めないと対話の claude が + 入力待ちで残り、続きの一時レーンの起動が同じ名前か上限で断られる。配布と保留の周回のどちらの起動も同じ。""" + for line in (prun.helper_spawn("p1", 2, 1), prun.helper_spawn("p1", 3, 0, pending="lroot")): + name = line.split("--name ", 1)[1].split()[0] + assert f"{prun.RELAY} stop {name}" in line.split(" -- ", 1)[1] diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index 092f6809c..fc9beb130 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -1386,6 +1386,32 @@ def test_waiting_only_instead_of_no_issue_when_only_pending_work_is_left(env): assert out["exit"] == 3 and out["metrics"]["stop"]["condition"] == "waiting_only" +def test_a_waiting_only_lane_stops_its_own_wrapper(env, monkeypatch): + """待ちだけで止まったレーンは自分のラッパーを止める 1 行を返す(判断 fca6fe464619 の (a))。ラッパーの外と、並列の計画の + レーンでないラッパーでは返さない。""" + import parallel_files + + request_lap(env) + env.rank_box["out"] = rank_out(ms("01 近い", [11, 12])) + monkeypatch.delenv("NDF_RELAY_NAME", raising=False) + out = sl_select.cmd_select(env, "l1") + assert out["metrics"]["stop"]["condition"] == "waiting_only" and out["metrics"].get("stop_self") is None + sl.cmd_start(env, "l1", [], {}) + monkeypatch.setenv("NDF_RELAY_NAME", "la") + out = sl_select.cmd_select(env, "l1") # 並列の計画のレーンでないラッパー(利用者の端末)は止めない + assert out["metrics"]["stop"]["condition"] == "waiting_only" and out["metrics"].get("stop_self") is None + plan = env.root / ".ndf" / "parallel" / "p1" + plan.mkdir(parents=True) + (plan / "plan.json").write_text(json.dumps({"name": "p1", "lanes": [{"name": "la"}]})) + sl.cmd_start(env, "l1", [], {}) + out = sl_select.cmd_select(env, "l1") + assert out["metrics"]["stop"]["condition"] == "waiting_only" and out["metrics"]["stop_self"] == parallel_files.stop_self(env.root) + assert out["metrics"]["stop_self"] + sl.cmd_start(env, "l1", [], {}) + sl.cmd_stop(env, "l1") + assert sl_select.cmd_select(env, "l1")["metrics"].get("stop_self") is None # 待ちでない止まり方はラッパーを残す + + def test_budget_waits_first_but_user_stop_stops_at_once(env): request_lap(env) r = rec(env) diff --git a/plugins/ndf/skills/development-workflow/references/asking.md b/plugins/ndf/skills/development-workflow/references/asking.md index 9df4622d8..d1c9788aa 100644 --- a/plugins/ndf/skills/development-workflow/references/asking.md +++ b/plugins/ndf/skills/development-workflow/references/asking.md @@ -66,7 +66,7 @@ hook が `parallel.py request-ask` でオーガナイザーへ送り、問いを | 送り手 | 保留の仕方 | 答えが届いたとき | | --- | --- | --- | | 開発ループのレーン | `sprint-state.py loop hold-question <ループ> --ref <依頼> --plan <止まったプラン> --step <続きのステップ>` で周回を「質問待ち」で締め、次の周回へ進む([loop.md](loop.md)) | 周回の境で、止まったプランを続きのステップから流す | -| 1 スプリントのレーン・一時レーン | 引継ぎ文書の「次にやること」に続きのコマンドと依頼の id を書き、`ndf-next` を出さずにセッションを終える | オーガナイザーが `relay.py restart <レーン>` で起こす。`parallel.py await --from` か答えのイベントを読み、続きから進む | +| 1 スプリントのレーン・一時レーン | 引継ぎ文書の「次にやること」に続きのコマンドと依頼の id を書き、`ndf-next` を出さずに、最後に `relay.py stop "$NDF_RELAY_NAME"` で自分のラッパーを止めてセッションを終える(1 行は保留の手順が返す) | オーガナイザーが `relay.py restart <レーン>` で起こす(起こす要否は引継ぎ文書の依頼の id で決める)。`parallel.py await --from` か答えのイベントを読み、続きから進む | 設計の承認(承認ゲート 1)と本番の配布(承認ゲート 2)は、レーンが依頼として送るため問わない。ループのレーンは承認ゲート 1 で 課題を人の判断待ちへ移すときに設計の承認の依頼を送り、本番のステージは配布の依頼のプランになる。 diff --git a/plugins/ndf/skills/development-workflow/references/loop.md b/plugins/ndf/skills/development-workflow/references/loop.md index a68e1346b..1810dc7e7 100644 --- a/plugins/ndf/skills/development-workflow/references/loop.md +++ b/plugins/ndf/skills/development-workflow/references/loop.md @@ -119,6 +119,10 @@ (`[ndf-loop] stopped <名> `)をそのまま出して**応答を終える。再開のコマンドは引継ぎ文書 `loop-<名>` の 「次に実行するコマンド」にもスクリプトが書く。 +`waiting_only` で止まった並列の計画のレーンは、結果に `metrics.stop_self`(自分のラッパーを止める 1 行)が付く。止まった行を出した後、 +最後にその 1 行を打って終える。打たないと対話の claude が入力待ちで残り、ラッパーが止まらない。オーガナイザーは、ループの記録が +`waiting_only` で止まっていることを見て、答えの後に `relay.py restart` で起こす。 + | 止まった理由 | 利用者へ問うか | | --- | --- | | `gate2_returned` | 問う(承認ゲート 2) | diff --git a/plugins/ndf/skills/development-workflow/references/parallel.md b/plugins/ndf/skills/development-workflow/references/parallel.md index ce4987cd7..3bf29f131 100644 --- a/plugins/ndf/skills/development-workflow/references/parallel.md +++ b/plugins/ndf/skills/development-workflow/references/parallel.md @@ -74,8 +74,8 @@ 最初の入力は次のどちらかで、`development-workflow` は判定の手順へ進まずこの節の手順で進める。 ```text -/goal /ndf:development-workflow --parallel-helper p1 <連番> <提示> -- 終わり: [ndf-parallel] helper-done p1 の行を出したとき -/goal /ndf:development-workflow --parallel-helper p1 pending <ループ名> -- 終わり: [ndf-parallel] helper-done p1 の行を出したとき +/goal /ndf:development-workflow --parallel-helper p1 <連番> <提示> -- 終わり: [ndf-parallel] helper-done p1 の行を出し、python3 stop p1-rel<連番> で自分のラッパーを止めたとき +/goal /ndf:development-workflow --parallel-helper p1 pending <ループ名> -- 終わり: [ndf-parallel] helper-done p1 の行を出し、python3 stop p1-rel<連番> で自分のラッパーを止めたとき ``` **配布(`<連番> <提示>`):** @@ -87,7 +87,9 @@ - `phase: confirmed`: 答えの記録 → 提示のプランを `approve` から → 本番のプランの queue と待ち。`approve` が承認ゲート(終了コード 10)で 終われば本番のプランを流さず、問いとして送って手順 4 へ進む 3. `metrics.settle` の 1 行(`parallel.py settle p1 <連番> --done `)を打つ。結果は耐久ワークフローが範囲のレーンへ返す -4. `[ndf-parallel] helper-done p1` の行を出して終える。答えの後は、オーガナイザーが続きの一時レーンを起こす +4. `[ndf-parallel] helper-done p1` の行を出し、最後に最初の入力の「終わり」にある `relay.py stop <自分の名前>` の 1 行を打って + 自分のラッパーを止めて終える。止めないと対話の claude が入力待ちで残り、続きの一時レーンの起動が同じ名前か上限で断られる。 + 答えの後は、オーガナイザーが続きの一時レーンを起こす CI の失敗・衝突・版数の衝突は、配布のプランの直しの手順(judge・fix)で直す。直せない失敗は問いにする(区分つきの `AskUserQuestion` を 出すと、問いの hook がオーガナイザーへ送り、拒否の理由に保留の手順が返る)。保留したら手順 4 で終える。 From b30dbf2cd6378eefc5758600abb7e4b7a6b12d81 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 16:37:14 +0000 Subject: [PATCH 65/70] =?UTF-8?q?Fix:=20=E4=BF=9D=E7=95=99=E4=B8=AD?= =?UTF-8?q?=E3=81=AE=E3=83=AC=E3=83=BC=E3=83=B3=E3=82=92=E4=BE=9D=E5=AD=98?= =?UTF-8?q?=E3=81=AE=E5=88=A4=E5=AE=9A=E3=81=A7=E6=AD=A2=E3=81=BE=E3=81=A3?= =?UTF-8?q?=E3=81=9F=E3=81=A8=E6=95=B0=E3=81=88=E3=81=AA=E3=81=84=E3=83=BB?= =?UTF-8?q?=E5=95=8F=E3=81=84=E3=82=92=E9=80=81=E3=81=A3=E3=81=A6=E6=AD=A2?= =?UTF-8?q?=E3=81=BE=E3=81=A3=E3=81=9F=E4=B8=80=E6=99=82=E3=83=AC=E3=83=BC?= =?UTF-8?q?=E3=83=B3=E3=82=92=E5=95=8F=E3=82=8F=E3=81=AA=E3=81=84=E3=83=BB?= =?UTF-8?q?=E3=81=BE=E3=81=A8=E3=82=81=E3=81=9F=E5=95=8F=E3=81=84=E3=81=AE?= =?UTF-8?q?=E5=85=A8=E6=8C=81=E3=81=A1=E4=B8=BB=E3=82=92=E8=B5=B7=E3=81=93?= =?UTF-8?q?=E3=81=99=E3=83=BB=E7=B5=82=E3=82=8F=E3=81=A3=E3=81=9F=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E9=A3=9B=E3=81=B0=E3=81=97=E3=81=A6=E5=95=8F?= =?UTF-8?q?=E3=81=84=E3=82=92=E9=80=81=E3=82=8B=EF=BC=88#2436=20=E3=81=AE?= =?UTF-8?q?=20cross-review=20round=209=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _spawn_ready: 答えていない問いを持つレーン・答えを当てていない revive・waiting_only で答えを待つループは止まったと数えない(I8)。答えた時点で止まった持ち主を起こす 1 行を添える - _ask_helper: 一時レーンが送った答えていない問い(ask も含む)があれば「配布の途中で止まった」を問わない(6g) - _owner_restart: 同じ原因としてまとめた問いは merged の全持ち主をそれぞれの依頼の id で調べて起こす(異常系 5) - ask_guard: 同じレーン名を持つ計画を順に試し、送り手でない(4)なら次の候補へ Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/hook_lib/ask_guard.py | 33 +++++++----- plugins/ndf/scripts/lib/parallel_files.py | 17 +++++-- plugins/ndf/scripts/lib/parallel_run.py | 41 +++++++++++++-- plugins/ndf/scripts/parallel.py | 12 +++-- plugins/ndf/scripts/tests/test_ask_guard.py | 18 ++++++- .../ndf/scripts/tests/test_parallel_run.py | 51 +++++++++++++++++-- 6 files changed, 144 insertions(+), 28 deletions(-) diff --git a/plugins/ndf/scripts/hook_lib/ask_guard.py b/plugins/ndf/scripts/hook_lib/ask_guard.py index 7074b71dc..b81788af0 100644 --- a/plugins/ndf/scripts/hook_lib/ask_guard.py +++ b/plugins/ndf/scripts/hook_lib/ask_guard.py @@ -87,15 +87,15 @@ def _record_verdict(ev, verdict, decl) -> str: return row["id"] -def run_request_ask(main: Path, plan: str, lane: str, payload: dict) -> tuple[int, dict]: - """`parallel.py request-ask` を子プロセスで打つ(上限 FORWARD_TIMEOUT 秒)。(終了コード, 統一 JSON レポート)。""" +def run_request_ask(main: Path, plan: str, lane: str, payload: dict, timeout: float = FORWARD_TIMEOUT) -> tuple[int, dict]: + """`parallel.py request-ask` を子プロセスで打つ(上限 `timeout` 秒)。(終了コード, 統一 JSON レポート)。""" got = subprocess.run( [sys.executable, str(PARALLEL_PY), "request-ask", plan, "--lane", lane], input=json.dumps(payload, ensure_ascii=False), capture_output=True, text=True, cwd=str(main), - timeout=FORWARD_TIMEOUT, + timeout=timeout, ) try: return got.returncode, json.loads(got.stdout.strip().splitlines()[-1]) if got.stdout.strip() else {} @@ -128,17 +128,26 @@ def forward_to_organizer(ev, row_id: str) -> dict | None: import decl import parallel_files + import time + main = decl.main_dir(ev.cwd) or Path(ev.cwd) - plan = parallel_files.lane_name_in_plans(main, name, os.environ.get("NDF_RELAY_DIR")) - if not plan: - return None - code, got = run_request_ask(main, plan, name, {"tool_input": ev.tool_input, "cwd": ev.cwd, "ledger_id": row_id}) - rid = (got.get("metrics") or {}).get("id") - if code != 0 or not rid: - return None - from hook_lib.token_guard import deny + payload = {"tool_input": ev.tool_input, "cwd": ev.cwd, "ledger_id": row_id} + deadline = time.monotonic() + FORWARD_TIMEOUT + # 同じレーン名を持つ終わった計画を飛ばす: 送り手でない計画(終了コード 4)なら次の候補へ(全体で FORWARD_TIMEOUT 秒まで) + for plan in parallel_files.lane_plans_in(main, name, os.environ.get("NDF_RELAY_DIR")): + left = deadline - time.monotonic() + if left <= 0: + return None + code, got = run_request_ask(main, plan, name, payload, timeout=left) + if code == 4: + continue + rid = (got.get("metrics") or {}).get("id") + if code != 0 or not rid: + return None + from hook_lib.token_guard import deny - return deny(hold_text(rid, plan)) + return deny(hold_text(rid, plan)) + return None def _context(text: str) -> dict: diff --git a/plugins/ndf/scripts/lib/parallel_files.py b/plugins/ndf/scripts/lib/parallel_files.py index 75ed49d4e..0e3cdddec 100644 --- a/plugins/ndf/scripts/lib/parallel_files.py +++ b/plugins/ndf/scripts/lib/parallel_files.py @@ -47,13 +47,24 @@ def helper_plan_of(relay_dir: Optional[str]) -> Optional[str]: def lane_name_in_plans(main: Path, name: Optional[str], relay_dir: Optional[str]) -> Optional[str]: """レーンか一時レーンかをファイルだけで見る(DBOS を読まない)。計画名か None。""" + got = lane_plans_in(main, name, relay_dir) + return got[0] if got else None + + +def lane_plans_in(main: Path, name: Optional[str], relay_dir: Optional[str]) -> list[str]: + """`name` をレーンに持つ計画の名前(並ぶ順)と、役割のファイルが示す一時レーンの計画。ファイルだけで見る(DBOS を読まない)。 + + 終わった計画と動いている計画が同じレーン名を持つことがあるため、どれが動いているかは送る側(`request-ask` の送り手の + 判定)が決める。問いの hook は候補を順に試す。""" if not name: - return None + return [] + out = [] for plan in plans_in(main): try: lanes = json.loads((Path(main) / PARALLEL_DIR / plan / "plan.json").read_text(encoding="utf-8")).get("lanes") or [] except (OSError, ValueError, AttributeError): continue if any(isinstance(ln, dict) and ln.get("name") == name for ln in lanes): - return plan - return helper_plan_of(relay_dir) + out.append(plan) + helper = helper_plan_of(relay_dir) + return [*out, helper] if helper and helper not in out else out diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index e1bd71bf2..3025b75b0 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -192,6 +192,22 @@ def design_held(main: Path, name: str, answered: list[str]) -> list[str]: return [ln.name for ln, rec in _loop_records(main, name) if held(rec)] +@durable.step(name="parallel.waiting-lanes") +def step_waiting_lanes(main: str, name: str) -> list[str]: + return waiting_lanes(Path(main), name) + + +def waiting_lanes(main: Path, name: str) -> list[str]: + """記録が `waiting_only` で止まり、答えをまだ当てていない待ちを持つループのレーンの名前(保留中。依存の判定で止まったと数えない)。""" + + def held(rec: dict) -> bool: + if rec.get("status") != "stopped" or (rec.get("stop") or {}).get("condition") != "waiting_only": + return False + return any(not w.get("answer") for w in rec.get("waiting") or []) + + return [ln.name for ln, rec in _loop_records(main, name) if held(rec)] + + @durable.step(name="parallel.build") def step_build(main: str, name: str, seq: int, k: int, requests: Optional[list[dict]] = None) -> dict: """まとめた配布の提示のプランと本番のプランを書き、受けている依頼を `releases/requests.json` へ書く @@ -346,6 +362,9 @@ def reply(self, msg: dict) -> None: self.publish_questions() owners = {q.get("lane"), *(m.get("lane") for m in q.get("merged", []))} & self.sprint & self.stopped() self.revive = sorted({*self.revive, *owners}) # 止まった持ち主を起こして答えを当てるまで計画を終えない + if owners - set(self.wake): # 保留中の持ち主の後に依存先を起こさないため、全レーンが止まるのを待たずに起こす 1 行を添える + self.wake = sorted({*self.wake, *owners}) + self.pause("notice", lanes=[], attach=[]) self.apply_own(q, list(msg.get("choice") or [])) def apply_own(self, q: dict, choice: list[str]) -> None: @@ -597,9 +616,22 @@ def _handle_stop(run: _Run, main: str) -> dict: return {**run.summary("stopped"), "not_stopped": left} +def _asking_lanes(run: _Run) -> set[str]: + """答えていない問い(まとめた問いの `merged` も含む)を送ったレーンの名前。""" + return {x for q in run.questions for x in (q.get("lane"), *(m.get("lane") for m in q.get("merged") or [])) if x} + + def _spawn_ready(run: _Run, main: str) -> None: - """起動の条件がそろったレーンを起動し、結果をレーンの状態へ反映する。""" - ready = pp.ready_lanes(_plan_view(run), set(_started(run)), run.stopped()) + """起動の条件がそろったレーンを起動し、結果をレーンの状態へ反映する。 + + 保留中のレーン(答えていない問いを持つ・答えをまだ当てていない `revive`・記録が `waiting_only` で答えを待つループ)は、 + ラッパーが止まっていても依存の判定では止まったと数えない(I8)。問いを保留して自分のラッパーを止めたレーンの後に、 + 依存先のレーンが起動して答えの後の再開と同時に動かないためである。""" + view, started = _plan_view(run), set(_started(run)) + stopped = run.stopped() - _asking_lanes(run) - set(run.revive) + ready = pp.ready_lanes(view, started, stopped) + if ready and any(a in stopped for ln in ready for a in ln.after): # 依存で起こすときだけループの記録を読む + ready = pp.ready_lanes(view, started, stopped - set(step_waiting_lanes(main, run.name))) if ready: for n, got in step_spawn(main, run.name, [ln.name for ln in ready]).items(): run.lanes[n].update(got) @@ -660,13 +692,16 @@ def _ask_stalls(run: _Run, rows: dict, now: float) -> None: def _ask_helper(run: _Run, rows: dict) -> None: - """配布の途中で一時レーンが止まり、settle も承認ゲート 2 の依頼も届いていなければ問う(受け入れ条件 6g)。""" + """配布の途中で一時レーンが止まり、settle も承認ゲート 2 の依頼も、その一時レーンが送った答えていない問い(ask も含む)も + 無ければ問う(受け入れ条件 6g)。一時レーンは問いを保留すると自分のラッパーを止めるため、問いを送って止まったものは問わない。""" rel = run.current if rel is None or not run.launch or run.helper_asked == run.launch["seq"]: return name = pr.helper_name(run.name, rel["n"]) if (rows.get(name) or {}).get("state") != "stopped" or any(q.get("kind") == "gate2" and q.get("n") == rel["n"] for q in run.questions): return + if name in _asking_lanes(run): + return run.helper_asked = run.launch["seq"] run.ask( "helper", f"{name}-h{run.launch['seq']}", name, f"一時レーン {name} が配布 {rel['n']} の途中で止まった(settle も承認ゲート 2 の依頼も無い)", diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index 056ec7ca6..e4a5857e9 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -366,16 +366,20 @@ def cmd_request_gate2(a) -> None: def _owner_restart(main: Path, name: str, rid: str, choice: list[str] | None = None) -> str | None: """答えの後の 1 行。自分で立てた問い(stall・helper・spawn)は選んだ答えの commands、ほかは答えを待って止まった持ち主の - restart。起こす要否はレーンの側の待ちの記録で決める(`parallel_lane.lane_waits`)。""" + restart。起こす要否はレーンの側の待ちの記録で決める(`parallel_lane.lane_waits`)。同じ原因としてまとめた問い(`merged`)は、 + 代表だけでなく全持ち主をそれぞれの依頼の id で調べ、起こす行を `; ` でつなぐ(異常系 5)。""" import durable import parallel_run as pr_run items = (durable.event_from(pl.KIND, pl.record_identity(main, name), pl.workflow_id(name), "questions", 0) or {}).get("items") or [] q = next((q for q in items if q.get("id") == rid), {}) - if q.get("kind") in ("stall", "helper", "spawn") or not (lane := q.get("lane")): + if q.get("kind") in ("stall", "helper", "spawn") or not q.get("lane"): return next((q["commands"][c] for c in choice or [] if (q.get("commands") or {}).get(c)), None) - waits = pl.lane_waits(main, name, lane, rid, lambda: pr_run.relay(main, "ls", "--json")) - return f"python3 {pr_run.RELAY} restart {lane}" if waits else None + ls = lambda: pr_run.relay(main, "ls", "--json") # noqa: E731 + owners = [(q["lane"], rid), *((m.get("lane"), m.get("id")) for m in q.get("merged") or [] if m.get("lane"))] + lanes = [lane for lane, each in owners if pl.lane_waits(main, name, lane, str(each or rid), ls)] + lanes = list(dict.fromkeys(lanes)) + return "; ".join(f"python3 {pr_run.RELAY} restart {lane}" for lane in lanes) or None def cmd_answer(a) -> None: diff --git a/plugins/ndf/scripts/tests/test_ask_guard.py b/plugins/ndf/scripts/tests/test_ask_guard.py index 8b3eea9e5..5dea04927 100644 --- a/plugins/ndf/scripts/tests/test_ask_guard.py +++ b/plugins/ndf/scripts/tests/test_ask_guard.py @@ -330,9 +330,10 @@ def forwarded(monkeypatch): box = {"code": 0, "calls": []} - def fake(main, plan, lane, payload): + def fake(main, plan, lane, payload, timeout=None): box["calls"].append((plan, lane, payload)) - return box["code"], {"metrics": {"id": f"{lane}-q{payload['ledger_id']}"}} + code = box.get("codes", {}).get(plan, box["code"]) + return code, {"metrics": {"id": f"{lane}-q{payload['ledger_id']}"}} monkeypatch.setattr(ask_guard, "run_request_ask", fake) return box @@ -353,6 +354,19 @@ def test_a_lane_question_is_sent_to_the_organizer_and_denied(proj, monkeypatch, assert (plan, lane) == ("p1", "la") and sent["tool_input"]["metadata"]["source"] == "ndf-ask:cost" and sent["ledger_id"] +def test_a_finished_plan_with_the_same_lane_name_is_skipped(proj, monkeypatch, forwarded): + """終わった計画 a と動いている計画 b が同じレーン名を持つと、a の送り手の判定(4)を飛ばして b へ送る。""" + for p in ("a", "b"): + d = proj / ".ndf" / "parallel" / p + d.mkdir(parents=True) + (d / "plan.json").write_text(json.dumps({"version": 1, "name": p, "rules": [], "lanes": [{"name": "la"}]})) + forwarded["codes"] = {"a": 4} + monkeypatch.setenv("NDF_RELAY_NAME", "la") + out = decide(proj, "ndf-ask:cost") + assert is_deny(out) and "並列の計画 b のレーン" in json.dumps(out, ensure_ascii=False) + assert [c[0] for c in forwarded["calls"]] == ["a", "b"] + + def test_the_hold_steps_end_by_stopping_the_own_wrapper(proj, monkeypatch, forwarded): """保留の手順は自分のラッパーを止めて終える(判断 fca6fe464619 の (a))。ndf-next を出さずに終えても対話の claude が残るためである。""" import parallel_files diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index 647b53519..6f4057dea 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -553,7 +553,8 @@ def test_the_plan_waits_until_the_owner_applies_a_design_answer(env, monkeypatch rid = pl.request_ask(main, "p1", "la", ASK, str(tmp_path), "d1") until(lambda: questions(main)["items"]) states.write_text(json.dumps({"la": "stopped", "lb": "stopped"})) - until(lambda: "lb" in spawned(env["log"])) + time.sleep(1.0) + assert "lb" not in spawned(env["log"]) # 保留中の la は依存の判定で止まったと数えない(I8) pl.send_answer(main, "p1", rid, "answered", choice=["済んだ"]) ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("wake") and e) assert ev["wake"] == [f"python3 {prun.RELAY} restart la"] @@ -623,17 +624,18 @@ def test_the_plan_does_not_finish_while_a_question_is_unanswered(env, tmp_path): start(main) rid = pl.request_ask(main, "p1", "la", ASK, str(tmp_path), "led9") until(lambda: questions(main)["items"]) - states.write_text(json.dumps({"la": "stopped", "lb": "stopped"})) - until(lambda: "lb" in spawned(env["log"])) + states.write_text(json.dumps({"la": "stopped", "lb": "stopped"})) # la が問いを保留して自分のラッパーを止めた time.sleep(1.0) + assert "lb" not in spawned(env["log"]) # 保留中の la は依存の判定で止まったと数えない(I8) assert durable.status(pl.workflow_id("p1")) in pl.RUNNING pl.send_answer(main, "p1", rid, "answered", choice=["済んだ"]) ev = until(lambda: (e := event(main, "p1", "pause")) and e.get("wake") and e) # 止まった持ち主が答えを当てるまで終えない assert ev["wake"] == [f"python3 {prun.RELAY} restart la"] - assert durable.status(pl.workflow_id("p1")) in pl.RUNNING + assert "lb" not in spawned(env["log"]) states.write_text(json.dumps({"la": "running", "lb": "stopped"})) time.sleep(1.0) # 動き出した la を観測させる states.write_text(json.dumps({"la": "stopped", "lb": "stopped"})) + until(lambda: "lb" in spawned(env["log"])) # 答えを当てて止まった la の後に lb を起こす assert durable.output_of(pl.workflow_id("p1"))["status"] == "finished" @@ -704,6 +706,9 @@ def test_owner_is_woken_by_the_lane_side_wait_record(monkeypatch, tmp_path): assert cli._owner_restart(tmp_path, "p1", "qb") == f"python3 {prun.RELAY} restart lb" items.append({"id": "qh", "kind": "ask", "lane": "p1-rel1"}) # 記録を持たない一時レーンはラッパーが止まっているかで決める assert cli._owner_restart(tmp_path, "p1", "qh") == f"python3 {prun.RELAY} restart p1-rel1" + # 同じ原因としてまとめた問いは、merged の持ち主もそれぞれの依頼の id で調べて起こす(異常系 5) + items.append({"id": "qm", "kind": "ask", "lane": "la", "merged": [{"id": "qb", "lane": "lb"}]}) + assert cli._owner_restart(tmp_path, "p1", "qm") == f"python3 {prun.RELAY} restart la; python3 {prun.RELAY} restart lb" def test_a_helper_ends_by_stopping_its_own_wrapper(): @@ -712,3 +717,41 @@ def test_a_helper_ends_by_stopping_its_own_wrapper(): for line in (prun.helper_spawn("p1", 2, 1), prun.helper_spawn("p1", 3, 0, pending="lroot")): name = line.split("--name ", 1)[1].split()[0] assert f"{prun.RELAY} stop {name}" in line.split(" -- ", 1)[1] + + +def _bare_run(monkeypatch, lanes): + """耐久ワークフローの外で _Run を組む(知らせは捨てる)。""" + monkeypatch.setattr(durable, "set_event", lambda *_a, **_k: None) + return prun._Run("/m", "p1", lanes) + + +def test_a_held_lane_does_not_release_its_dependents(monkeypatch): + """問いを保留して止まったレーン・答えを当てていない revive・waiting_only で待つループは、依存の判定で止まったと数えない(I8)。""" + run = _bare_run(monkeypatch, [{"name": "la", "input": {"kind": "sprint"}}, {"name": "lb", "after": ["la"]}]) + run.lanes["la"].update(started=True, state="stopped") + spawned, waiting = [], [] + monkeypatch.setattr(prun, "step_spawn", lambda _m, _n, names: spawned.extend(names) or {n: {"started": True} for n in names}) + monkeypatch.setattr(prun, "step_waiting_lanes", lambda *_a: list(waiting)) + run.questions = [{"id": "q1", "kind": "ask", "lane": "la"}] + prun._spawn_ready(run, "/m") + run.questions, run.revive = [], ["la"] + prun._spawn_ready(run, "/m") + run.revive, waiting[:] = [], ["la"] + prun._spawn_ready(run, "/m") + assert spawned == [] + waiting.clear() + prun._spawn_ready(run, "/m") + assert spawned == ["lb"] + + +def test_a_helper_that_stopped_after_asking_is_not_asked_about(monkeypatch): + """一時レーンが ask を送って自分のラッパーを止めたときは、「配布の途中で止まった」を問わない(6g)。""" + run = _bare_run(monkeypatch, [{"name": "la"}]) + run.current, run.launch = {"n": 1}, {"seq": 3, "spawn": "SPAWN"} + helper = prl.helper_name("p1", 1) + run.questions = [{"id": "qa", "kind": "ask", "lane": helper}] + prun._ask_helper(run, {helper: {"state": "stopped"}}) + assert [q["kind"] for q in run.questions] == ["ask"] + run.questions = [] + prun._ask_helper(run, {helper: {"state": "stopped"}}) + assert [q["kind"] for q in run.questions] == ["helper"] From b578f2d39088828dd131303c95e671ff5f29cb20 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 16:57:00 +0000 Subject: [PATCH 66/70] =?UTF-8?q?Fix:=20=E5=87=BA=E3=81=97=E7=9B=B4?= =?UTF-8?q?=E3=81=97=E3=81=A7=E9=81=B8=E3=82=93=E3=81=A0=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E3=81=AE=E7=89=88=E3=81=A7=E9=85=8D=E5=B8=83=E3=81=AE=E7=AD=94?= =?UTF-8?q?=E3=81=88=E3=82=92=E8=BF=94=E3=81=99=E3=83=BB=E9=81=85=E3=82=8C?= =?UTF-8?q?=E3=81=9F=E4=BE=9D=E9=A0=BC=E3=81=B8=E6=9C=80=E5=BE=8C=E3=81=AE?= =?UTF-8?q?=20PR=20=E3=81=8C=E5=87=BA=E3=81=9F=E9=85=8D=E5=B8=83=E3=81=AE?= =?UTF-8?q?=E7=89=88=E3=81=A8=E9=80=A3=E7=95=AA=E3=81=A7=E7=AD=94=E3=81=88?= =?UTF-8?q?=E3=82=8B=EF=BC=88#2436=20=E3=81=AE=20cross-review=20round=2010?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_run.py | 10 ++++-- .../ndf/scripts/tests/test_parallel_run.py | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 3025b75b0..4c51cc126 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -395,11 +395,13 @@ def answer(self, rid: str, result: str, version: Optional[str], n: Optional[int] # -- 配布 -- def answer_from_ranges(self) -> None: - """出した配布の範囲で答えられる依頼に答えて外す(I15)。依頼の全 PR がそれまでに出せた範囲の和に入れば答える。""" - done = {n for r in self.releases if r.get("state") == "released" for n in r.get("prs") or []} + """出した配布の範囲で答えられる依頼に答えて外す(I15)。依頼の全 PR がそれまでに出せた範囲の和に入れば答える。 + 和は古い配布から順に足し、依頼の最後の PR が出た配布の版と連番で答える(後の配布の PR を前の配布の版で答えない)。""" + done: set[int] = set() for rel in self.releases: if rel.get("state") != "released": continue + done |= set(rel.get("prs") or []) hit = set(pr.answerable(self.pending, rel.get("prs") or [], done)) for r in [r for r in self.pending if r["id"] in hit]: self.answer(r["id"], "released", rel.get("version"), rel["n"]) @@ -456,13 +458,15 @@ def confirm(self, rel: dict, take: Optional[str]) -> None: if pr.moved(first.get("sha") or "", head): built = step_build(self.main, self.name, rel["n"], 2, self.pending) rel["dirs"].append(built["dir"]) + rel["versions"] = [rel.get("version"), built.get("version")] # 提示ごとの版。選ばれた提示の版を配布の答えへ渡す self.emit_release(rel, 2, represent=True) return k = 1 else: k = 2 if take == "new" else 1 got = step_presented(rel["dirs"][k - 1]) - rel.update(state="confirmed", k=k, sha=got.get("sha"), prs=got.get("prs") or []) + versions = rel.get("versions") or [rel.get("version")] + rel.update(state="confirmed", k=k, sha=got.get("sha"), prs=got.get("prs") or [], version=versions[min(k, len(versions)) - 1]) self.pause( "confirmed", n=rel["n"], k=k, plan=str(Path(rel["dirs"][k - 1]) / "release.json"), spawn=helper_spawn(self.name, rel["n"], k) ) diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index 6f4057dea..cdc7a6485 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -755,3 +755,36 @@ def test_a_helper_that_stopped_after_asking_is_not_asked_about(monkeypatch): run.questions = [] prun._ask_helper(run, {helper: {"state": "stopped"}}) assert [q["kind"] for q in run.questions] == ["helper"] + + +def test_settle_answers_with_the_version_of_the_chosen_presentation(env, tmp_path, monkeypatch): + """出し直しの提示 2 を選んだら、提示 2 を組んだ時点の版で依頼へ答える(settle に版が無くても提示 1 の版にしない)。""" + main, heads = env["main"], env["heads"] + start(main) + rid = pl.send_release_request(main, "p1", "la", [10], [1], tmp_path / "st") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "release") + (prl.release_dir(main, "p1", 1, 1) / "presented.json").write_text(json.dumps({"sha": "sha1", "prs": [10]})) + heads["v"] = "sha2" + monkeypatch.setattr(prun, "dev_version", lambda _m: "1.2.4") # 提示 1 の後に別レーンの開発版が進んだ + resume(main, {"n": 1, "phase": "confirm"}, "c1") + until(lambda: (e := event(main, "p1", "pause")) and e.get("represent")) + (prl.release_dir(main, "p1", 1, 2) / "presented.json").write_text(json.dumps({"sha": "sha2", "prs": [10]})) + resume(main, {"n": 1, "phase": "confirm", "take": "new"}, "c2") + until(lambda: (e := event(main, "p1", "pause")) and e.get("action") == "confirmed") + resume(main, {"n": 1, "phase": "settle", "result": "released"}, "s1") + ans, _ = until(lambda: pl.read_answer(main, "p1", rid)[0] and pl.read_answer(main, "p1", rid)) + assert ans["version"] == "1.2.4" + + +def test_a_late_request_is_answered_by_the_release_that_completed_it(monkeypatch): + """配布 1 と配布 2 の PR を両方含む遅れた依頼には、配布 2 の版と連番で答える(I15)。""" + run = _bare_run(monkeypatch, [{"name": "la"}]) + got = [] + monkeypatch.setattr(run, "answer", lambda rid, result, version, n, reason=None: got.append((rid, version, n))) + run.releases = [ + {"n": 1, "state": "released", "version": "1.2.3", "prs": [10]}, + {"n": 2, "state": "released", "version": "1.2.4", "prs": [20]}, + ] + run.pending = [{"id": "r1", "lane": "la", "prs": [10, 20]}, {"id": "r0", "lane": "la", "prs": [10]}] + run.answer_from_ranges() + assert sorted(got) == [("r0", "1.2.3", 1), ("r1", "1.2.4", 2)] and run.pending == [] From 00cba4a4e0f950f6ac95c0e52108efb6ee93ae2a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 17:06:30 +0000 Subject: [PATCH 67/70] =?UTF-8?q?fix(parallel):=20=E8=B5=B7=E3=81=93?= =?UTF-8?q?=E3=81=97=E3=81=9F=E3=83=AC=E3=83=BC=E3=83=B3=E3=81=AE=E6=9C=80?= =?UTF-8?q?=E5=BE=8C=E3=81=AB=E5=8B=95=E3=81=84=E3=81=9F=E6=99=82=E5=88=BB?= =?UTF-8?q?=E3=81=A7=20revive=20=E3=82=92=E5=A4=96=E3=81=99=20(#2452)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 答えを当ててすぐ止まるレーンは観測の間隔の中で stopped → running → stopped と動き、 状態の変化として見えないため revive が残り、計画が finished へ進まなかった。 relay.py ls の at が答えたときの観測の時刻より後へ進んだら revive から外す。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_run.py | 24 +++++++++++++++++-- .../ndf/scripts/tests/test_parallel_run.py | 20 ++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 4c51cc126..65e696a59 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -287,6 +287,8 @@ def __init__(self, main: str, name: str, lanes: list[dict]): self.wake: list[str] = [] self.sprint = {ln["name"] for ln in lanes if (ln.get("input") or {}).get("kind") == "sprint"} self.revive: list[str] = [] # 問いへ答えた後、まだ動き出していない止まった 1 スプリントのレーン(6f) + self.revive_at: dict[str, float] = {} # revive に入れたときの最後の観測の時刻(エポック秒)。最後に動いた時刻と比べる + self.seen_at = 0.0 # 最後に観測した時刻(エポック秒) def bump(self) -> int: self.seq += 1 @@ -362,6 +364,7 @@ def reply(self, msg: dict) -> None: self.publish_questions() owners = {q.get("lane"), *(m.get("lane") for m in q.get("merged", []))} & self.sprint & self.stopped() self.revive = sorted({*self.revive, *owners}) # 止まった持ち主を起こして答えを当てるまで計画を終えない + self.revive_at.update({o: self.seen_at for o in owners}) # 止まったと観た時刻。これより後に動いたら起きたと数える if owners - set(self.wake): # 保留中の持ち主の後に依存先を起こさないため、全レーンが止まるのを待たずに起こす 1 行を添える self.wake = sorted({*self.wake, *owners}) self.pause("notice", lanes=[], attach=[]) @@ -661,7 +664,9 @@ def _observe_run(run: _Run, main: str) -> None: seen = step_observe(main) rows = seen.get("rows") or {} states = {n: r.get("state") for n, r in rows.items()} - _ask_stalls(run, rows, float(seen.get("now") or 0)) + run.seen_at = float(seen.get("now") or 0) + _clear_revived(run, rows) + _ask_stalls(run, rows, run.seen_at) _ask_helper(run, rows) noticed = [] for n in run.order: @@ -670,13 +675,28 @@ def _observe_run(run: _Run, main: str) -> None: st = "stopped" if st and st != run.lanes[n].get("state"): run.lanes[n]["state"] = st - run.revive = [x for x in run.revive if x != n or st == "stopped"] # 起こしたレーンが動き出したら待ちを外す + if st != "stopped" and n in run.revive: # 起こしたレーンが動き出したら待ちを外す + run.revive = [x for x in run.revive if x != n] + run.revive_at.pop(n, None) if st == "awaiting_approval": noticed.append(n) if noticed: run.pause("notice", lanes=noticed, attach=[f"python3 {RELAY} attach {n}" for n in noticed]) +def _clear_revived(run: _Run, rows: dict) -> None: + """起こしたレーンの最後に動いた時刻(`relay.py ls` の `at`)が、答えたときの観測の時刻より後へ進んだら `revive` から外す。 + 答えを当ててすぐ止まるレーンは観測の間隔の中で stopped → running → stopped と動き、状態の変化として見えないためである。""" + import clock + + for n in list(run.revive): + at = (rows.get(n) or {}).get("at") + t = clock.parse(at, naive="utc") if at else None + if t is not None and t.timestamp() > run.revive_at.get(n, float("inf")): + run.revive = [x for x in run.revive if x != n] + run.revive_at.pop(n, None) + + def _ask_stalls(run: _Run, rows: dict, now: float) -> None: """承認待ちでも止まってもいないのに最後に動いた時刻が区切りより前のレーンを、区切りごとに 1 回だけ問う(異常系 3)。""" import clock diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index cdc7a6485..b33c180d5 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -744,6 +744,26 @@ def test_a_held_lane_does_not_release_its_dependents(monkeypatch): assert spawned == ["lb"] +def test_a_revived_lane_that_stops_between_observations_leaves_revive(monkeypatch): + """起こしたレーンが観測の間隔の中で stopped → running → stopped と動いても、最後に動いた時刻が答えたときの観測より + 後へ進めば revive から外す。外さないと _finish_or_hold が設計の承認待ちを空にできず、計画が終わらない。""" + run = _bare_run(monkeypatch, [{"name": "la", "input": {"kind": "sprint"}}]) + seen = {"now": 1_000_000.0, "rows": {"la": {"state": "stopped", "at": "1970-01-12T13:40:00Z"}}} # 999_600 + monkeypatch.setattr(prun, "step_observe", lambda _m: seen) + run.lanes["la"].update(started=True) + prun._observe_run(run, "/m") + run.questions = [{"id": "q1", "kind": "ask", "lane": "la"}] + run.reply({"id": "q1", "result": "answered"}) + assert run.revive == ["la"] + seen["now"] = 1_000_060.0 + prun._observe_run(run, "/m") + assert run.revive == ["la"] # まだ動いていない + seen["rows"]["la"]["at"] = "1970-01-12T13:46:50Z" # 1_000_010。答えの後に動いて、また止まった + seen["now"] = 1_000_120.0 + prun._observe_run(run, "/m") + assert run.revive == [] + + def test_a_helper_that_stopped_after_asking_is_not_asked_about(monkeypatch): """一時レーンが ask を送って自分のラッパーを止めたときは、「配布の途中で止まった」を問わない(6g)。""" run = _bare_run(monkeypatch, [{"name": "la"}]) From 493ed26512da6d601078b8faac8d6ec450e7374b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 17:27:02 +0000 Subject: [PATCH 68/70] =?UTF-8?q?Fix:=20=E3=82=B9=E3=83=97=E3=83=AA?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=81=A8=E5=91=A8=E5=9B=9E=E3=81=AE=20queue?= =?UTF-8?q?=20=E3=81=AE=201=20=E8=A1=8C=E3=82=82=E3=83=AC=E3=83=BC?= =?UTF-8?q?=E3=83=B3=E3=81=AE=E3=83=97=E3=83=A9=E3=83=B3=E3=81=AE=E6=9C=AC?= =?UTF-8?q?=E6=95=B0=E3=81=A7=E6=B5=81=E3=81=99=E3=83=BB=E3=83=AC=E3=83=BC?= =?UTF-8?q?=E3=83=B3=E3=81=AE=E9=96=93=E3=81=A7=E5=BC=95=E7=B6=99=E3=81=8E?= =?UTF-8?q?=E6=96=87=E6=9B=B8=E3=81=8C=E9=87=8D=E3=81=AA=E3=82=8B=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E6=8B=92=E3=82=80=EF=BC=88#2436=20=E3=81=AE?= =?UTF-8?q?=20cross-review=20round=2012=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - queue の本数の判定を parallel_lane.queue_max へ寄せ、supervise.py queue の既定・スプリントのステージの 1 行・周回のまとめの 1 行・待ち時間の調査が同じ値を使う - 並列の計画の検査で、handoff_name() の保存先が重なるレーンを I1 で拒む Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_lane.py | 20 +++++++++++++++++++ plugins/ndf/scripts/lib/parallel_plan.py | 4 ++++ plugins/ndf/scripts/lib/sprint_loop_gate.py | 11 ++++++---- plugins/ndf/scripts/supervise.py | 10 +--------- plugins/ndf/scripts/supervise_lib/sprint.py | 4 +++- .../ndf/scripts/tests/test_parallel_lane.py | 11 ++++++++++ .../ndf/scripts/tests/test_parallel_plan.py | 1 + plugins/ndf/scripts/tests/test_sprint_loop.py | 2 +- 8 files changed, 48 insertions(+), 15 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py index 4fb035d4d..fb7990853 100644 --- a/plugins/ndf/scripts/lib/parallel_lane.py +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -94,6 +94,26 @@ def lane_plans(main: Path, lane: Optional[str] = None) -> Optional[int]: return got[1].plans if got else None +QUEUE_MAX = 3 # 並列の計画のレーンでないときの `queue --max` の既定 + + +def queue_max(cwd: Optional[str] = None) -> int: + """`queue` の同時に流す本数。並列の計画のレーンならレーンのプランの本数、そうでなければ 3(#2436)。 + + `queue` の `--max` の既定と、スプリント・周回の続きが組む queue の 1 行が同じ値を使う(レーンの本数を超えない)。""" + import sys + + import decl + + root = cwd or os.getcwd() + try: + n = lane_plans(decl.main_dir(root) or Path(root)) + except Exception as exc: # noqa: BLE001 判定できなければ今どおりの 3 で流す(理由は標準エラーへ) + print(f"[supervise] レーンの判定に失敗したため --max の既定 {QUEUE_MAX} で流す: {exc}", file=sys.stderr) + n = None + return n or QUEUE_MAX + + def merged_on_production(root: Path, prs: list[int], run=subprocess.run) -> bool: """`prs` のマージのコミットがすべて本番チャネル(`repo.production_branch`)の `origin/<本番>` にあるか(I15)。 diff --git a/plugins/ndf/scripts/lib/parallel_plan.py b/plugins/ndf/scripts/lib/parallel_plan.py index 4d0fa1255..56c6ae8b4 100644 --- a/plugins/ndf/scripts/lib/parallel_plan.py +++ b/plugins/ndf/scripts/lib/parallel_plan.py @@ -153,6 +153,10 @@ def _whole(self, info: ValidationInfo) -> "ParallelPlan": dup = sorted({n for n in names if names.count(n) > 1}) if dup: raise PydanticCustomError("I1", "レーン名が重なる: {names}", {"names": ", ".join(dup)}) + docs = [ln.handoff_name() for ln in self.lanes] + dup = sorted({d for d in docs if docs.count(d) > 1}) + if dup: # 同じ引継ぎ文書へ書くと、後のレーンの文書が先のレーンの文書を上書きする + raise PydanticCustomError("I1", "レーンの引継ぎ文書が重なる: {names}", {"names": ", ".join(dup)}) cap = int(ctx.get("max_named") or DEFAULT_MAX_NAMED) if len(self.lanes) > cap: raise PydanticCustomError("I2", "レーンの数 {n} が同時に動かせる数の上限 {cap} を超える", {"n": len(self.lanes), "cap": cap}) diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index beaf8ea14..d9a84b2ab 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -66,13 +66,14 @@ def select_cmd(name: str) -> str: return f"python3 {shlex.quote(str(SPRINT_STATE))} loop select {name}" -QUEUE_MAX = 3 # まとめの queue の同時に流す計画の上限(supervise.py queue --max) DONE_RESULTS = ("完了", NOT_RUN) # 周回を完了で締められる done の結果(流さなかった計画は前のステージの結果に従う) def queue_command(stages: list[list[str]], done: Path) -> str: - """まとめの queue の 1 行: 先頭のステージを --max で流し、残りのステージを --then でつなぎ、--done で終わる。""" - head = f"python3 {shlex.quote(str(SUPERVISE))} queue " + " ".join(map(shlex.quote, stages[0])) + f" --max {QUEUE_MAX}" + """まとめの queue の 1 行: 先頭のステージを --max(レーンならレーンのプランの本数)で流し、残りのステージを --then でつなぎ、--done で終わる。""" + import parallel_lane + + head = f"python3 {shlex.quote(str(SUPERVISE))} queue " + " ".join(map(shlex.quote, stages[0])) + f" --max {parallel_lane.queue_max()}" return head + "".join(" --then " + " ".join(map(shlex.quote, s)) for s in stages[1:]) + f" --done {shlex.quote(str(done))}" @@ -511,9 +512,11 @@ def refine_summary(env: Env, rec: dict) -> dict: def research_command(root: str, rec: dict, lap: dict, done: Path) -> str: """待ち(`supervise.py wait`)と並べて背景で打つ、待ち時間の調査の 1 行(#2315 の決定 7)。""" + import parallel_lane + plans = done.with_suffix(".plans.json") args = ["python3", str(UPKEEP), "research", "run", "--root", root, "--until", str(done), "--queue", str(plans)] - args += ["--max", str(QUEUE_MAX)] # 調査はまとめの queue と同じ並列の上限に数える + args += ["--max", str(parallel_lane.queue_max(root))] # 調査はまとめの queue と同じ並列の上限に数える return shlex.join([*args, "--lap", str(lap["n"]), "--loop", rec["name"]]) diff --git a/plugins/ndf/scripts/supervise.py b/plugins/ndf/scripts/supervise.py index fc6b70e6b..5a9ccb10c 100755 --- a/plugins/ndf/scripts/supervise.py +++ b/plugins/ndf/scripts/supervise.py @@ -79,7 +79,6 @@ import argparse import json -import os import sys from pathlib import Path @@ -288,16 +287,9 @@ def _cmd_example(ap: argparse.ArgumentParser, a: argparse.Namespace) -> int: def default_max(cwd: str | None = None) -> int: """`queue` の `--max` を省いたときの本数。並列の計画のレーンならレーンのプランの本数、そうでなければ 3(#2436)。""" - import decl import parallel_lane - root = cwd or os.getcwd() - try: - n = parallel_lane.lane_plans(decl.main_dir(root) or Path(root)) - except Exception as exc: # noqa: BLE001 判定できなければ今どおりの 3 で流す(理由は標準エラーへ) - print(f"[supervise] レーンの判定に失敗したため --max の既定 3 で流す: {exc}", file=sys.stderr) - n = None - return n or 3 + return parallel_lane.queue_max(cwd) def main() -> int: diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index 7d1e21027..70c2fd760 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -409,6 +409,8 @@ def _write_plans(wave: dict, i: int, out: Path) -> list[str]: def _stage_entry(wave: dict, i: int, out: Path) -> dict: """ステージ 1 件の一覧の項目。then_of のステージは command を持たず、前のステージへ連結される。""" + import parallel_lane + entry = {"wave": i, "name": wave["name"]} if "gate" in wave: entry["gate"] = wave["gate"] @@ -426,7 +428,7 @@ def _stage_entry(wave: dict, i: int, out: Path) -> dict: entry["command"] = ( f"python3 {shlex.quote(str(SELF))} queue " + " ".join(map(shlex.quote, paths)) - + f" --max 3 --done {shlex.quote(entry['done'])}" + + f" --max {parallel_lane.queue_max()} --done {shlex.quote(entry['done'])}" ) return entry diff --git a/plugins/ndf/scripts/tests/test_parallel_lane.py b/plugins/ndf/scripts/tests/test_parallel_lane.py index 1ceed2413..6501596c7 100644 --- a/plugins/ndf/scripts/tests/test_parallel_lane.py +++ b/plugins/ndf/scripts/tests/test_parallel_lane.py @@ -45,6 +45,17 @@ def test_queue_max_default(tmp_path, monkeypatch): assert supervise.default_max(str(tmp_path / "other")) == 3 +def test_generated_queue_lines_use_lane_plans(tmp_path, monkeypatch): + """スプリントのステージと周回のまとめが組む queue の 1 行も、レーンのプランの本数で流す。""" + import sprint_loop_gate as gate + from supervise_lib import sprint + + monkeypatch.setattr(pl, "lane_plans", lambda m, lane=None: 1) + wave = {"name": "実装", "plans": {"a": {"name": "a", "steps": []}}} + assert " --max 1 --done " in sprint._stage_entry(wave, 1, tmp_path)["command"] + assert " --max 1 --done " in gate.queue_command([["a.json"]], tmp_path / "done.json") + + def test_non_lane_sprint_keeps_prod_plan(monkeypatch): from supervise_lib import sprint diff --git a/plugins/ndf/scripts/tests/test_parallel_plan.py b/plugins/ndf/scripts/tests/test_parallel_plan.py index ad4358fb1..8db0f98e1 100644 --- a/plugins/ndf/scripts/tests/test_parallel_plan.py +++ b/plugins/ndf/scripts/tests/test_parallel_plan.py @@ -57,6 +57,7 @@ def test_valid_plan_passes(): (variant(name="a" * 25), "I1"), (variant(x=lambda d: d["lanes"][0].__setitem__("name", "p1-rel1")), "I1"), (variant(x=lambda d: d["lanes"][1].__setitem__("name", "lroot")), "I1"), + (variant(x=lambda d: d["lanes"][2].__setitem__("handoff", "loop-lflow")), "I1"), (variant(x=lambda d: d["lanes"][0].__setitem__("plans", 3)), "I2"), (variant(x=lambda d: d["lanes"].append({**d["lanes"][0], "name": "l4", "plans": 0})), "form"), (variant(x=lambda d: d["lanes"][2].__setitem__("after", ["nope"])), "I3"), diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index fc9beb130..bde04a128 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -632,7 +632,7 @@ def test_lap_records_depths_and_research_and_counts_research_in_the_budget(env): out = sl.cmd_sprint(env, "l1", "standard", "1.0.0-dev.1", None, []) research = out["metrics"]["research"] assert "research run" in research and "--until" in research and "--lap 1 --loop l1" in research - assert f"--max {sl_gate.QUEUE_MAX}" in research # まとめの queue と同じ並列の上限 + assert "--max 3" in research # まとめの queue と同じ並列の上限 b = env.root / ".ndf" / "backlog" (b / "research").mkdir(parents=True) (b / "depths.jsonl").write_text( From df9653bb5a101c01d52b653e867153a98388576d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 17:47:47 +0000 Subject: [PATCH 69/70] =?UTF-8?q?Fix:=20script-structure=20=E3=81=AE?= =?UTF-8?q?=E8=A1=8C=E6=95=B0=E9=81=95=E5=8F=8D=E3=82=92=E8=A7=A3=E3=81=8F?= =?UTF-8?q?=EF=BC=88parallel.py=20=E3=81=AE=E6=8C=AF=E3=82=8A=E5=88=86?= =?UTF-8?q?=E3=81=91=E3=82=92=20globals=20=E3=81=A7=E5=BC=95=E3=81=84?= =?UTF-8?q?=E3=81=A6=20487=20=E8=A1=8C=E3=83=BBparallel=5Frun.py=20?= =?UTF-8?q?=E3=81=A8=20sprint=5Floop=5Fgate.py=20=E3=81=AE=E4=BE=8B?= =?UTF-8?q?=E5=A4=96=E3=81=AE=E8=A1=8C=E6=95=B0=E3=82=92=E4=BB=8A=E3=81=AE?= =?UTF-8?q?=E8=A1=8C=E6=95=B0=E3=81=B8=EF=BC=89=EF=BC=88#2436=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/parallel.py | 18 +----------------- ...__scripts__lib__parallel_run.py--lines.json | 2 +- ...ripts__lib__sprint_loop_gate.py--lines.json | 2 +- 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index e4a5857e9..47bca6f51 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -480,23 +480,7 @@ def build_parser() -> argparse.ArgumentParser: def main(argv=None) -> None: a = build_parser().parse_args(argv) - { - "init": cmd_init, - "check": cmd_check, - "start": cmd_start, - "run": cmd_run, - "wait": cmd_wait, - "present": cmd_present, - "confirm": cmd_confirm, - "settle": cmd_settle, - "stop": cmd_stop, - "request": cmd_request, - "await": cmd_await, - "request-ask": cmd_request_ask, - "request-gate2": cmd_request_gate2, - "answer": cmd_answer, - "helper": cmd_helper, - }[a.cmd](a) + globals()["cmd_" + a.cmd.replace("-", "_")](a) if __name__ == "__main__": diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json index 3459fa0b2..b483189e7 100644 --- a/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/scripts/lib/parallel_run.py", "name": "", "kind": "lines", "reason": "#2436 で並列の計画の耐久ワークフロー(耐久ステップ・_Run の周回・一時レーンの起動・外の入口)を 1 モジュールに置いて 723 行。500 行以下にしたら消す。分けるなら、外の世界の読み出し(relay・base_tracking_ref・release_plugin・git_head・dev_version)と一時レーンの組み立て(helper_spawn・present_approval・helper_task)を別のモジュールへ移す", "lines": 723} +{"path": "plugins/ndf/scripts/lib/parallel_run.py", "name": "", "kind": "lines", "reason": "#2436 で並列の計画の耐久ワークフロー(耐久ステップ・_Run の周回・一時レーンの起動・外の入口)を 1 モジュールに置いて 723 行、cross-review の直しで 778 行。500 行以下にしたら消す。分けるなら、外の世界の読み出し(relay・base_tracking_ref・release_plugin・git_head・dev_version)と一時レーンの組み立て(helper_spawn・present_approval・helper_task)を別のモジュールへ移す", "lines": 778} diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json index b93dccb79..9948b0fd2 100644 --- a/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/scripts/lib/sprint_loop_gate.py", "name": "", "kind": "lines", "reason": "498 行だったところへ #2436(承認ゲートの問いをオーガナイザーへ送る経路・レーンの配布待ち)で増えて 582 行。500 行以下にしたら消す。保留の周回は sprint_loop_gate2.py へ分けてある", "lines": 582} +{"path": "plugins/ndf/scripts/lib/sprint_loop_gate.py", "name": "", "kind": "lines", "reason": "498 行だったところへ #2436(承認ゲートの問いをオーガナイザーへ送る経路・レーンの配布待ち)で増えて 585 行。500 行以下にしたら消す。保留の周回は sprint_loop_gate2.py へ分けてある", "lines": 585} From 061008b2141ce7babebcbe13b2ce0b7e12da83bd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 3 Nov 2026 18:12:47 +0000 Subject: [PATCH 70/70] =?UTF-8?q?Docs:=20relay.md=20=E3=81=AE=E3=80=8C?= =?UTF-8?q?=E5=88=A9=E7=94=A8=E4=B8=8A=E9=99=90=E3=81=A7=E3=82=A2=E3=82=AB?= =?UTF-8?q?=E3=82=A6=E3=83=B3=E3=83=88=E3=82=92=E6=9B=BF=E3=81=88=E3=81=A6?= =?UTF-8?q?=E7=B6=9A=E3=81=91=E3=82=8B=E3=80=8D=E3=82=92=20relay-accounts.?= =?UTF-8?q?md=20=E3=81=B8=E5=88=86=E3=81=91=E3=82=8B=EF=BC=88=E8=A1=8C?= =?UTF-8?q?=E6=95=B0=E3=81=AE=E4=B8=8A=E9=99=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KYHP826qdiDahV9gSeoGPK --- docs/specifications/ndf-relay-auth-failure.md | 6 +- .../ndf/skills/development-workflow/SKILL.md | 2 +- .../references/relay-accounts.md | 172 ++++++++++++++++++ .../development-workflow/references/relay.md | 169 +---------------- 4 files changed, 177 insertions(+), 172 deletions(-) create mode 100644 plugins/ndf/skills/development-workflow/references/relay-accounts.md diff --git a/docs/specifications/ndf-relay-auth-failure.md b/docs/specifications/ndf-relay-auth-failure.md index e2ab0a542..e96eac30b 100644 --- a/docs/specifications/ndf-relay-auth-failure.md +++ b/docs/specifications/ndf-relay-auth-failure.md @@ -20,8 +20,8 @@ | 何を読むか | 正本 | | --- | --- | -| 401 のときにラッパーが何をするか(替え先の順・直し方の 3 つ・区間ごとに 1 度)・再開の最初の入力 | [`development-workflow` の `references/relay.md`](../../plugins/ndf/skills/development-workflow/references/relay.md) の「アカウントの切り替え」の表の「認証が通らなかった(401)」 | -| リフレッシュトークンが空のアカウントが `再登録が要る` になること・起動時の 1 行・`account list` が認証ファイルを確かめないこと・「1 アカウント = 1 ログイン」 | 同じ `relay.md` の `account list` の状態の説明 | +| 401 のときにラッパーが何をするか(替え先の順・直し方の 3 つ・区間ごとに 1 度)・再開の最初の入力 | [`development-workflow` の `references/relay-accounts.md`](../../plugins/ndf/skills/development-workflow/references/relay-accounts.md) の「アカウントの切り替え」の表の「認証が通らなかった(401)」 | +| リフレッシュトークンが空のアカウントが `再登録が要る` になること・起動時の 1 行・`account list` が認証ファイルを確かめないこと・「1 アカウント = 1 ログイン」 | 同じ `relay-accounts.md` の `account list` の状態の説明 | | `log.jsonl` の `account` の行の項目 | 同じ `relay.md` の「記録の読み方」 | | 画面の文(直し方の 1 行・従量の接続へ替えた 1 行・既定のログインから替えた 1 行・背景の作業を待つ 1 行) | [`relay_lib/switch.py`](../../plugins/ndf/scripts/relay_lib/switch.py) の `auth_guide`・`login_on`・`_msg_auth`・`AccountSwitch.tell_account`・`_wait_background` | | 再登録が要るにする判定 | [`lib/claude_accounts.py`](../../plugins/ndf/scripts/lib/claude_accounts.py) の `_usable_held` | @@ -172,7 +172,7 @@ refreshToken は調べない(決定 8)。 | refreshToken を持たない正しいログインの形 | `account add` の登録は `claude auth login` の OAuth で refreshToken を持つ前提である。refreshToken を持たない正しい認証ファイル(長期の token など)が登録済みアカウントに入る経路は確かめていない。あれば I3 で外れ、起動時に「再登録が要る」と出る | | Claude Code が refreshToken を空にする時点 | `invalid_grant` で空にする振る舞いは 2026-10-04 の観測からの推定で、本体の版で変わりうる(順応者)。変われば I3 が働く場面が減るだけで、誤って外すことはない | | 既定のログインの 401 から替えた後 | 登録が 1 つ以下で替えた区間では `multi` が偽のままなので、使用率の定期の確認と上限の切り替えは働かない(I4) | -| `account list` の表示 | `usable` を通らないため、refreshToken が空のアカウントはラッパーの起動か切り替えの前は `使える` と出る(`relay.md` に書いてある) | +| `account list` の表示 | `usable` を通らないため、refreshToken が空のアカウントはラッパーの起動か切り替えの前は `使える` と出る(`relay-accounts.md` に書いてある) | ## テスト観点 diff --git a/plugins/ndf/skills/development-workflow/SKILL.md b/plugins/ndf/skills/development-workflow/SKILL.md index fc8a694b6..a6a7eca45 100644 --- a/plugins/ndf/skills/development-workflow/SKILL.md +++ b/plugins/ndf/skills/development-workflow/SKILL.md @@ -350,7 +350,7 @@ conductor がフェーズごとにプランをキューで流す([references/w **カットポイントの再起動はラッパーが自動で行う(Claude Code だけ)。** 利用者が `claude` と打つと alias がラッパーを挟む。 ラッパーは conductor が出した `ndf-next` のブロックを拾い、`/exit`・プラグインの更新・次のセッションの起動を行う。 -始め方・止め方・上限は [references/relay.md](references/relay.md) にある。 +始め方・止め方・上限は [references/relay.md](references/relay.md)、利用上限でのアカウントの切り替えは [references/relay-accounts.md](references/relay-accounts.md) にある。 **ブロックの前に次の Bash を 1 回実行し、2 行目(アナウンス)をブロックの直前へそのままコピーする。** 1 行目が `outside` か失敗ならラッパーの外である(書き方は `context-window.md` の「新しい会話で戻す」)。 diff --git a/plugins/ndf/skills/development-workflow/references/relay-accounts.md b/plugins/ndf/skills/development-workflow/references/relay-accounts.md new file mode 100644 index 000000000..cdabd853a --- /dev/null +++ b/plugins/ndf/skills/development-workflow/references/relay-accounts.md @@ -0,0 +1,172 @@ +# 利用上限でアカウントを替えて続ける + +[relay.md](relay.md)(ラッパーでカットポイントを自動にする)から分けた、利用上限でラッパーが claude のアカウントを替えて続ける規約である。 + +**claude のアカウントを 2 つ以上登録しておくと、利用上限で止まらずに別のアカウントで続く。** 端末で打てば +`claude auth login` が URL を示し、認可コードの貼り付けを待つ(1 回で終わる)。端末でなければ 2 回に分かれる(下の +「AI が代わりに打つとき」)。**Linux(コンテナを含む)だけで使える。** macOS の +claude はログインの資格情報を Keychain に置き、設定ディレクトリの `.credentials.json` を書かないため、macOS の +`account add` は登録の前に止まる。1 つのメールアドレスで複数の組織(個人と Team など)に属していれば、組織ごとに別の +アカウントとして登録できる(ログインのときに組織を選ぶ。同じメールアドレスと組織の 2 つ目は拒む)。 + +```bash +python3 ~/.claude/ndf/relay.py account add work1 # 専用の設定ディレクトリで claude auth login が動く +python3 ~/.claude/ndf/relay.py account add work2 +python3 ~/.claude/ndf/relay.py account list # 推論を呼ばずに残量を出す(--json もある) +python3 ~/.claude/ndf/relay.py account capacity work2 - 900 # 週の枠の大きさだけを 900 USD と宣言する(- - で外す) +python3 ~/.claude/ndf/relay.py account remove work2 +``` + +**`account use <名前>` で、動いているラッパーでも次の区間の起動(`ndf-next` の再起動・起動の直後)から使うアカウントを指定できる**(`python3 ~/.claude/ndf/relay.py account use work1`)。 +指定は置き場の `pinned.json` に書き、区間の起動のたびに読む。指定が使えれば閾値や残りの量に依らずそれで起動し(`account` の行の`reason` は `pinned`)、使えない(上限・認証の失敗・再登録が要る)間は今の選び方で替えて続け、使えるようになった後の区間の起動で +戻る。`--clear` で外すと今の選び方へ戻る。登録に無い名前と `再登録が要る` 名前は終了コード 1(`reason` は `not_registered`・`needs_relogin`)で断る。同じ置き場を使うラッパーすべてに効く。`account list` は名前に `(指定)`(`--json` は `pinned`)、`relay.py ls` は表の下の 1 行と `metrics.pinned_account` で示す。動いているラッパーは [relay.md](relay.md) の「ラッパーの入れ替え」で新しい版へ替わった後の区間から読む。 + +**`account` の副命令はすべて `--yes`(確認を飛ばす)と `--json`(結果を JSON 1 つで標準出力へ返す)を受ける。** +対話で聞く値(名前・プロファイル・地域・モデル・確認)はすべて引数でも渡せ、揃っていれば何も聞かない。標準入力が +端末でなければ入力を待たず、足りない引数の名前(`--json` なら `missing` と `candidates`)を示して終了コード 2 で終わる。 + +## AI が代わりに打つとき + +conductor・worker は Bash から打つ(標準入力は端末でない)。人が要るのは、ブラウザでの認可と、画面に出た認可コードを +AI へ返すことだけである。 + +```bash +python3 ~/.claude/ndf/relay.py account add work2 --json +# → {"ok": true, "stage": "awaiting_code", "url": "https://…", "expires_at": "…"}。URL を利用者へ渡す +printf '%s\n' '<認可コード>' | python3 ~/.claude/ndf/relay.py account add work2 --code - --json +# → {"ok": true, "stage": "registered", "email": "…"} +``` + +1 回目は `claude auth login` を待たせたまま(待機中のログイン)認可の URL を返す。登録の途中の状態は置き場の +`.pending-<名前>/` に残り、10 分を過ぎると次の `account` の呼び出しで捨てる(2 回目は `expired` で 1)。認可コードは +`--code -`(標準入力の 1 行)で渡す。引数に載せると `ps` と打った側の記録に残る。 + +```text +名前 識別 5 時間 7 日 モデル別の週 支出上限 枠の大きさ 残り 状態 +work1 a@example.com 50%(04:59) 3%(09-29) Fable 58%(09-29) 達していない 210 / 1,100 105 使える +work2 b@example.com 40%(05:40) 0%(09-30) - 達していない 52.5 / 900* 31.5 使える +``` + +**アカウントは残りの量(USD 換算)の大きい順に選ぶ。** 残りの量は、5 時間の枠・週の枠・モデル別の週の枠(`limits[]` の +`weekly_scoped`)ごとの「枠の大きさ ×(1 − 使用率 / 100)」の最小である。枠の大きさは `account capacity` の宣言(`*` 付き) +→ `.credentials.json` の `rateLimitTier` から引く対応表の順に決まる。モデル別の週の枠の大きさは週の枠と同じとする。 + +| `rateLimitTier` | 5 時間の枠(USD) | 週の枠(USD) | +| --- | ---: | ---: | +| `default_claude_max_20x` | 210 | 1,100 | +| `default_claude_max_5x` | 52.5 | 640(仮の値) | + +表に無い tier(Team premium など)で宣言も無いアカウントは、枠の大きさが `-` になり、残りの量の分かるアカウントの後ろへ +使用率の小さい順で並ぶ。使用率が切り替えの閾値以上のアカウントは、閾値未満のアカウントより後に試す。支出上限は +`extra_usage.spend_limit_reached` が真のときだけ達したとする。`spend.percent` ≥ 100 か `spend.severity` = `critical` +(追加の利用の予算の使い切りと見られる)は判定に使わず、生の旗が真でなければ支出上限の列に `no(追加の利用は上限)` と +補足する(`--json` は `spend_full`)。生の旗が偽でも spend が 100%・critical を返すことがあり、解除の時刻を持たないため、 +判定に使うと上限から外れなくなる(#2036)。 + +識別の列は、同じメールアドレスの登録が 2 件以上あるときだけ `a@example.com(Team A)` のように組織名を添える。 +従量の接続の宣言があれば、最後に `metered` の行(識別は `Bedrock(<プロファイル>・<地域>・<モデル>)` か +`環境変数(<変数の名前>)`、状態は `保存した宣言`・`環境変数の宣言`・`壊れている(宣言なしとして扱う)` のどれか)が出る。 + +状態は `使える`・`上限(<リセット時刻>)`・`支出上限`・`残量不明`・`認証の失敗`・`再登録が要る` の 6 つ。`再登録が要る` は +同じ名前で `account add` し直す。`認証の失敗` は子の応答が認証の失敗で終わったアカウントで、後の残量の取得が通るか 1 時間で +`使える` へ戻る。 + +認証ファイルのリフレッシュトークンが空のアカウント(claude がトークンの更新を断られると空にする)は、アクセストークンが +期限内でも `再登録が要る` になり、切り替え先に選ばれない。ラッパーは起動のたびに、`再登録が要る` アカウントを 1 つにつき +1 行(`ndf-relay: アカウント <名前> は再登録が要る。python3 ~/.claude/ndf/relay.py account add <名前> で登録し直す`)で +知らせる。`account list` は認証ファイルを確かめないので、ラッパーの起動か切り替えの前は `使える` と出ることがある。 +同じアカウントを既定のログイン(`~/.claude`)と登録済みアカウントの両方で別々にログインしない(1 アカウント = 1 ログイン)。 +別々のログインは別々のリフレッシュトークンを持ち、片方の更新が断られると、そちらだけが使えなくなる。 + +**アカウントの設定ディレクトリが、そのアカウントで起動する claude の `CLAUDE_CONFIG_DIR` になる。** 置き場は +`${CLAUDE_CONFIG_DIR:-~/.claude}/ndf/accounts/`(0700)で、アカウントごとの `<名前>/`(0700)に認証ファイル +(`.credentials.json`)・`.claude.json`・NDF の記録(0600)を実体で持ち、ほかは共有の設定ディレクトリ(`~/.claude`)の直下への +symlink にする。会話の記録(`projects`)・設定(`settings.json`)・プラグインは共有側の 1 か所に入るので、アカウントを替えても +`--resume` と設定がそのまま効く。子へはトークンもスコープも渡さず、claude が自分の認証ファイルでトークンを更新する(8 時間で +認証切れにならない)。`/status`・statusline・`claude auth status` には、そのアカウントの email と契約の種類が出る。共有の +`~/.claude/.credentials.json` は読み書きしないので、素の claude で `/login` を打っても、ラッパーの下の claude のアカウントは +替わらない。 + +| 子の環境の変数 | 値 | +| --- | --- | +| `CLAUDE_CONFIG_DIR` | アカウントの設定ディレクトリ(`<置き場>/<名前>`) | +| `NDF_CLAUDE_ACCOUNT` | アカウントの名前 | +| `NDF_SHARED_CONFIG_DIR` | 元の `CLAUDE_CONFIG_DIR`(無かったら空文字)。子の中の `supervise.py` が共有の設定ディレクトリを求める | +| `CLAUDE_CODE_PLUGIN_CACHE_DIR` | `<共有>/plugins`(元にあればその値)。子が導入したプラグインの記録を共有側のパスにする | +| `CLAUDE_CODE_OAUTH_TOKEN`・`CLAUDE_CODE_OAUTH_SCOPES` | 渡さない(元の環境にあっても外す) | + +起動の前に、ラッパーと `supervise.py` はアカウントの設定ディレクトリを用意する。共有側に増えた項目の symlink を足し、 +共有の `~/.claude.json` の `projects`(プロジェクトの信頼・MCP サーバーの許可)と `mcpServers` をアカウント側の `.claude.json` へ +コピーする(`oauthAccount` はアカウントごとのまま)。ラッパーは、セッションの切れ目と終了時に、アカウント側で変わった分を共有の +`~/.claude.json` へ書き戻す。claude.ai のコネクタは、claude が認証ファイルのスコープを自分で読んで取りに行く。従量の接続の子は +共有の設定ディレクトリで起動する(`CLAUDE_CONFIG_DIR` を元の値へ戻す)。 + +次の表に当たるアカウントでは claude を起動せず、次の候補へ移る。理由は画面の 1 行(`ndf-relay: アカウント <名前> を使わない(<理由>)`)と +`log.jsonl` の `account_dir` の行に残る。 + +| 理由 | 直し方 | +| --- | --- | +| 認証ファイルが別のアカウントのものになっている(アカウントの claude の中で `/login` を打った) | `account add <名前>` で登録し直す | +| 会話の記録の置き場 `projects` が共有の設定ディレクトリを指していない | アカウントの設定ディレクトリの `projects` の中身を共有の `~/.claude/projects/` へ移し、`projects` を消す(次の起動で symlink になる) | +| アカウント側の `.claude.json` が壊れている | 下の 2 つのどちらか | + +アカウント側の `.claude.json` が JSON として読めないときは、NDF は直さない。アカウントの設定ディレクトリの +`backups/.claude.json.backup.<ミリ秒>` のうち JSON として読める新しいものを `.claude.json` へ `cp` するか、`.claude.json` を +消す(`oauthAccount` は claude が次の応答で取り直し、最初の案内の印は共有側からコピーされる)。どちらの後も、次の起動で共有側の +`projects` と `mcpServers` にそろう。 + +**`/mcp` で OAuth 認証した MCP サーバー(claude.ai のコネクタでないもの)は、アカウントごとに認証が要る。** そのトークンは +認証ファイルに入るため、共有側で済ませた認証はアカウントの子に届かない。アカウントごとに 1 度、そのアカウントの対話の +セッションで `/mcp` から認証する。対話を持たない claude -p の worker は認証し直せないので、認証の済んでいないアカウントで +動く worker はそのサーバーの Tool を使えない。登録し直すと、そのアカウントでもう 1 度認証が要る。 + +同じ名前で登録し直しても、アカウントの設定ディレクトリの symlink と、claude がアカウント側に作った項目(`plans` など)は残る。 +共有側に無くアカウント側にだけある項目は、アカウントを替えた先から見えない。`account_dir` の行の `local_only` に名前が出るので、 +共有したいものは共有の `~/.claude/` へ移す(次の起動で symlink になる)。登録を外しても(`account remove`)共有側は消えない。 + +| いつ | ラッパー | `supervise.py`(プランの claude -p) | +| --- | --- | --- | +| 起動 | `account use` の指定が使えればそれを使う。無ければ区間ごとに、上限に達していないアカウントのうち残りの量の最も大きいものを選ぶ。今のアカウントが閾値未満なら替えず、閾値を超えていても候補の残りの量が今以下なら替えない | 起動したときのアカウント(`NDF_CLAUDE_ACCOUNT`)で呼ぶ | +| 利用上限(5 時間・7 日・支出上限) | 子の応答が上限で終わると(`StopFailure` hook の `limit.json`)、次の区間を別のアカウントで `claude --resume <会話> "<最初の入力>"` として起動する。最初の入力は `ndf-next` があればそれ、無ければ未達の `/goal <条件>`、それも無ければ切り替えの理由に合う定型の文(例: `利用上限でアカウントを替えた。中断したところから続ける`)。**バックグラウンドの作業が残っている間は子を終えない** | 待たずに別のアカウントで同じ呼び出しをやり直す | +| 認証が通らなかった(401) | 子の応答が認証の失敗で終わると、登録済みアカウントならそのアカウントを 1 時間候補から外し、上限のときと同じ順(使える登録済みアカウント → 従量の接続の宣言)で次の区間を起動する(最初の入力は `認証が通らなかったためアカウントを替えた。中断したところから続ける`)。既定のログインでも同じく替え、登録が 1 つ以下でも替えた先を以後の区間で使い続ける。従量の接続の 401 では替えない。替え先が無ければ、ログインの形に合う直し方を 1 行出して子を残す(既定のログイン: claude の中で `/login`。登録済みアカウント: `relay.py account add <名前>`。従量の接続: `relay.py account check metered` で宣言を確かめる)。1 つの区間につき 1 度だけ | 結果が認証の失敗なら、そのアカウントを外して同じ呼び出しをやり直す | +| 使用率が閾値を超えた | 定期の確認(推論なし)が 1 行を出し、**次のカットポイントで**替える | — | +| すべて上限 | 宣言があれば従量の接続へ移る。無ければ 1 行を出して子を残す | 宣言があれば従量の接続へ移る。無ければ今と同じ上限待ち | +| 従量の接続で動いている間 | 定期の確認で上限の外れたアカウントを見つけたら、次のカットポイントで戻す | 起動のたびに戻れるかを確かめる | + +**従量の接続(Bedrock)は `account add-bedrock` で登録する。** AWS のプロファイルは先に `aws configure sso` などで +作っておく。候補(`aws configure list-profiles`)が 1 つなら確認だけ、複数なら番号で選ぶ。地域はプロファイルの `region`、 +モデルは Bedrock の推論プロファイルのうち Claude のものから選ぶ。保存の前に `aws sts get-caller-identity` と +`aws bedrock-runtime converse`(最大 1 トークン。1 回分の費用が掛かる)で呼べるかを確かめ、呼べなければ理由(`認証切れ`・ +`権限が無い`・`地域で使えない`・`モデルが有効でない`)と AWS のエラーの種類の名前を出して保存しない。macOS でも使える。 + +```bash +python3 ~/.claude/ndf/relay.py account add-bedrock # 端末で選ぶ +python3 ~/.claude/ndf/relay.py account add-bedrock --profile bedrock-dev --model <モデルの ID> --yes --json +python3 ~/.claude/ndf/relay.py account check metered # 今の宣言で呼べるかを確かめる +python3 ~/.claude/ndf/relay.py account remove metered # 保存した宣言を外す +``` + +宣言はアカウントの置き場の `metered.json`(0600)に保存し、次の起動から効く。シェルの設定ファイルは書き換えない。 +保存するのは変数の名前と値(`CLAUDE_CODE_USE_BEDROCK`・`AWS_PROFILE`・`AWS_REGION`・`ANTHROPIC_MODEL`)だけで、AWS の鍵は +`~/.aws/` に残る。保存した宣言で起動する子からは、環境の AWS の鍵の変数を外す。宣言は 1 つだけで、置き換えは確認 +(`--yes`)が要る。保存先が壊れていれば、ラッパーは区間 1 の起動で、`supervise.py` は最初の呼び出しの前に 1 行出し、 +宣言なしとして扱う。 + +**環境変数で上書きする場合は `NDF_SUPERVISE_CLAUDE_FALLBACK` を定義する**(`KEY=VALUE` を空白区切り。値はリポジトリに +書かない)。定義されていれば(空でも)保存した宣言は読まない。Bedrock 以外(API キーなど)もこの形で宣言する。 + +```bash +export NDF_SUPERVISE_CLAUDE_FALLBACK='CLAUDE_CODE_USE_BEDROCK=1 AWS_PROFILE=<名前> AWS_REGION=us-east-1' +``` + +従量の接続で起動する子にはアカウントの設定ディレクトリを渡さず、アカウントで起動する子には宣言の変数を渡さない(混ぜない)。記録と画面に +出るのは変数の名前だけである。**登録が 1 つ以下なら今と同じ**(ラッパーは上限で何もしない。`supervise.py` は宣言を +呼び出しごとに 1 度だけ試してから上限待ち)。切り替えに LLM は使わない。 + +| 設定 | 既定 | 意味 | +| --- | --- | --- | +| `NDF_ACCOUNT_CHECK_INTERVAL` | 300 | 使用量の取得先を 1 アカウントにつき何秒に 1 回まで呼ぶか。定期の確認の間隔も兼ねる | +| `NDF_ACCOUNT_SWITCH_AT` | 90 | 閾値(%)。100 以上なら閾値では替えない | + +替えるたびに画面へ `ndf-relay:` の 1 行(例: `利用上限(five_hour)に達したため、アカウントを work1 から work2(b@example.com)へ替えて続ける`) +が出て、`log.jsonl` に `account` の行が残る。 diff --git a/plugins/ndf/skills/development-workflow/references/relay.md b/plugins/ndf/skills/development-workflow/references/relay.md index aa7199f3e..aac5370e2 100644 --- a/plugins/ndf/skills/development-workflow/references/relay.md +++ b/plugins/ndf/skills/development-workflow/references/relay.md @@ -213,174 +213,7 @@ conductor では、コンテキスト量の hook が工程へ入る起動を 1 ## 利用上限でアカウントを替えて続ける -**claude のアカウントを 2 つ以上登録しておくと、利用上限で止まらずに別のアカウントで続く。** 端末で打てば -`claude auth login` が URL を示し、認可コードの貼り付けを待つ(1 回で終わる)。端末でなければ 2 回に分かれる(下の -「AI が代わりに打つとき」)。**Linux(コンテナを含む)だけで使える。** macOS の -claude はログインの資格情報を Keychain に置き、設定ディレクトリの `.credentials.json` を書かないため、macOS の -`account add` は登録の前に止まる。1 つのメールアドレスで複数の組織(個人と Team など)に属していれば、組織ごとに別の -アカウントとして登録できる(ログインのときに組織を選ぶ。同じメールアドレスと組織の 2 つ目は拒む)。 - -```bash -python3 ~/.claude/ndf/relay.py account add work1 # 専用の設定ディレクトリで claude auth login が動く -python3 ~/.claude/ndf/relay.py account add work2 -python3 ~/.claude/ndf/relay.py account list # 推論を呼ばずに残量を出す(--json もある) -python3 ~/.claude/ndf/relay.py account capacity work2 - 900 # 週の枠の大きさだけを 900 USD と宣言する(- - で外す) -python3 ~/.claude/ndf/relay.py account remove work2 -``` - -**`account use <名前>` で、動いているラッパーでも次の区間の起動(`ndf-next` の再起動・起動の直後)から使うアカウントを指定できる**(`python3 ~/.claude/ndf/relay.py account use work1`)。 -指定は置き場の `pinned.json` に書き、区間の起動のたびに読む。指定が使えれば閾値や残りの量に依らずそれで起動し(`account` の行の`reason` は `pinned`)、使えない(上限・認証の失敗・再登録が要る)間は今の選び方で替えて続け、使えるようになった後の区間の起動で -戻る。`--clear` で外すと今の選び方へ戻る。登録に無い名前と `再登録が要る` 名前は終了コード 1(`reason` は `not_registered`・`needs_relogin`)で断る。同じ置き場を使うラッパーすべてに効く。`account list` は名前に `(指定)`(`--json` は `pinned`)、`relay.py ls` は表の下の 1 行と `metrics.pinned_account` で示す。動いているラッパーは「ラッパーの入れ替え」で新しい版へ替わった後の区間から読む。 - -**`account` の副命令はすべて `--yes`(確認を飛ばす)と `--json`(結果を JSON 1 つで標準出力へ返す)を受ける。** -対話で聞く値(名前・プロファイル・地域・モデル・確認)はすべて引数でも渡せ、揃っていれば何も聞かない。標準入力が -端末でなければ入力を待たず、足りない引数の名前(`--json` なら `missing` と `candidates`)を示して終了コード 2 で終わる。 - -### AI が代わりに打つとき - -conductor・worker は Bash から打つ(標準入力は端末でない)。人が要るのは、ブラウザでの認可と、画面に出た認可コードを -AI へ返すことだけである。 - -```bash -python3 ~/.claude/ndf/relay.py account add work2 --json -# → {"ok": true, "stage": "awaiting_code", "url": "https://…", "expires_at": "…"}。URL を利用者へ渡す -printf '%s\n' '<認可コード>' | python3 ~/.claude/ndf/relay.py account add work2 --code - --json -# → {"ok": true, "stage": "registered", "email": "…"} -``` - -1 回目は `claude auth login` を待たせたまま(待機中のログイン)認可の URL を返す。登録の途中の状態は置き場の -`.pending-<名前>/` に残り、10 分を過ぎると次の `account` の呼び出しで捨てる(2 回目は `expired` で 1)。認可コードは -`--code -`(標準入力の 1 行)で渡す。引数に載せると `ps` と打った側の記録に残る。 - -```text -名前 識別 5 時間 7 日 モデル別の週 支出上限 枠の大きさ 残り 状態 -work1 a@example.com 50%(04:59) 3%(09-29) Fable 58%(09-29) 達していない 210 / 1,100 105 使える -work2 b@example.com 40%(05:40) 0%(09-30) - 達していない 52.5 / 900* 31.5 使える -``` - -**アカウントは残りの量(USD 換算)の大きい順に選ぶ。** 残りの量は、5 時間の枠・週の枠・モデル別の週の枠(`limits[]` の -`weekly_scoped`)ごとの「枠の大きさ ×(1 − 使用率 / 100)」の最小である。枠の大きさは `account capacity` の宣言(`*` 付き) -→ `.credentials.json` の `rateLimitTier` から引く対応表の順に決まる。モデル別の週の枠の大きさは週の枠と同じとする。 - -| `rateLimitTier` | 5 時間の枠(USD) | 週の枠(USD) | -| --- | ---: | ---: | -| `default_claude_max_20x` | 210 | 1,100 | -| `default_claude_max_5x` | 52.5 | 640(仮の値) | - -表に無い tier(Team premium など)で宣言も無いアカウントは、枠の大きさが `-` になり、残りの量の分かるアカウントの後ろへ -使用率の小さい順で並ぶ。使用率が切り替えの閾値以上のアカウントは、閾値未満のアカウントより後に試す。支出上限は -`extra_usage.spend_limit_reached` が真のときだけ達したとする。`spend.percent` ≥ 100 か `spend.severity` = `critical` -(追加の利用の予算の使い切りと見られる)は判定に使わず、生の旗が真でなければ支出上限の列に `no(追加の利用は上限)` と -補足する(`--json` は `spend_full`)。生の旗が偽でも spend が 100%・critical を返すことがあり、解除の時刻を持たないため、 -判定に使うと上限から外れなくなる(#2036)。 - -識別の列は、同じメールアドレスの登録が 2 件以上あるときだけ `a@example.com(Team A)` のように組織名を添える。 -従量の接続の宣言があれば、最後に `metered` の行(識別は `Bedrock(<プロファイル>・<地域>・<モデル>)` か -`環境変数(<変数の名前>)`、状態は `保存した宣言`・`環境変数の宣言`・`壊れている(宣言なしとして扱う)` のどれか)が出る。 - -状態は `使える`・`上限(<リセット時刻>)`・`支出上限`・`残量不明`・`認証の失敗`・`再登録が要る` の 6 つ。`再登録が要る` は -同じ名前で `account add` し直す。`認証の失敗` は子の応答が認証の失敗で終わったアカウントで、後の残量の取得が通るか 1 時間で -`使える` へ戻る。 - -認証ファイルのリフレッシュトークンが空のアカウント(claude がトークンの更新を断られると空にする)は、アクセストークンが -期限内でも `再登録が要る` になり、切り替え先に選ばれない。ラッパーは起動のたびに、`再登録が要る` アカウントを 1 つにつき -1 行(`ndf-relay: アカウント <名前> は再登録が要る。python3 ~/.claude/ndf/relay.py account add <名前> で登録し直す`)で -知らせる。`account list` は認証ファイルを確かめないので、ラッパーの起動か切り替えの前は `使える` と出ることがある。 -同じアカウントを既定のログイン(`~/.claude`)と登録済みアカウントの両方で別々にログインしない(1 アカウント = 1 ログイン)。 -別々のログインは別々のリフレッシュトークンを持ち、片方の更新が断られると、そちらだけが使えなくなる。 - -**アカウントの設定ディレクトリが、そのアカウントで起動する claude の `CLAUDE_CONFIG_DIR` になる。** 置き場は -`${CLAUDE_CONFIG_DIR:-~/.claude}/ndf/accounts/`(0700)で、アカウントごとの `<名前>/`(0700)に認証ファイル -(`.credentials.json`)・`.claude.json`・NDF の記録(0600)を実体で持ち、ほかは共有の設定ディレクトリ(`~/.claude`)の直下への -symlink にする。会話の記録(`projects`)・設定(`settings.json`)・プラグインは共有側の 1 か所に入るので、アカウントを替えても -`--resume` と設定がそのまま効く。子へはトークンもスコープも渡さず、claude が自分の認証ファイルでトークンを更新する(8 時間で -認証切れにならない)。`/status`・statusline・`claude auth status` には、そのアカウントの email と契約の種類が出る。共有の -`~/.claude/.credentials.json` は読み書きしないので、素の claude で `/login` を打っても、ラッパーの下の claude のアカウントは -替わらない。 - -| 子の環境の変数 | 値 | -| --- | --- | -| `CLAUDE_CONFIG_DIR` | アカウントの設定ディレクトリ(`<置き場>/<名前>`) | -| `NDF_CLAUDE_ACCOUNT` | アカウントの名前 | -| `NDF_SHARED_CONFIG_DIR` | 元の `CLAUDE_CONFIG_DIR`(無かったら空文字)。子の中の `supervise.py` が共有の設定ディレクトリを求める | -| `CLAUDE_CODE_PLUGIN_CACHE_DIR` | `<共有>/plugins`(元にあればその値)。子が導入したプラグインの記録を共有側のパスにする | -| `CLAUDE_CODE_OAUTH_TOKEN`・`CLAUDE_CODE_OAUTH_SCOPES` | 渡さない(元の環境にあっても外す) | - -起動の前に、ラッパーと `supervise.py` はアカウントの設定ディレクトリを用意する。共有側に増えた項目の symlink を足し、 -共有の `~/.claude.json` の `projects`(プロジェクトの信頼・MCP サーバーの許可)と `mcpServers` をアカウント側の `.claude.json` へ -コピーする(`oauthAccount` はアカウントごとのまま)。ラッパーは、セッションの切れ目と終了時に、アカウント側で変わった分を共有の -`~/.claude.json` へ書き戻す。claude.ai のコネクタは、claude が認証ファイルのスコープを自分で読んで取りに行く。従量の接続の子は -共有の設定ディレクトリで起動する(`CLAUDE_CONFIG_DIR` を元の値へ戻す)。 - -次の表に当たるアカウントでは claude を起動せず、次の候補へ移る。理由は画面の 1 行(`ndf-relay: アカウント <名前> を使わない(<理由>)`)と -`log.jsonl` の `account_dir` の行に残る。 - -| 理由 | 直し方 | -| --- | --- | -| 認証ファイルが別のアカウントのものになっている(アカウントの claude の中で `/login` を打った) | `account add <名前>` で登録し直す | -| 会話の記録の置き場 `projects` が共有の設定ディレクトリを指していない | アカウントの設定ディレクトリの `projects` の中身を共有の `~/.claude/projects/` へ移し、`projects` を消す(次の起動で symlink になる) | -| アカウント側の `.claude.json` が壊れている | 下の 2 つのどちらか | - -アカウント側の `.claude.json` が JSON として読めないときは、NDF は直さない。アカウントの設定ディレクトリの -`backups/.claude.json.backup.<ミリ秒>` のうち JSON として読める新しいものを `.claude.json` へ `cp` するか、`.claude.json` を -消す(`oauthAccount` は claude が次の応答で取り直し、最初の案内の印は共有側からコピーされる)。どちらの後も、次の起動で共有側の -`projects` と `mcpServers` にそろう。 - -**`/mcp` で OAuth 認証した MCP サーバー(claude.ai のコネクタでないもの)は、アカウントごとに認証が要る。** そのトークンは -認証ファイルに入るため、共有側で済ませた認証はアカウントの子に届かない。アカウントごとに 1 度、そのアカウントの対話の -セッションで `/mcp` から認証する。対話を持たない claude -p の worker は認証し直せないので、認証の済んでいないアカウントで -動く worker はそのサーバーの Tool を使えない。登録し直すと、そのアカウントでもう 1 度認証が要る。 - -同じ名前で登録し直しても、アカウントの設定ディレクトリの symlink と、claude がアカウント側に作った項目(`plans` など)は残る。 -共有側に無くアカウント側にだけある項目は、アカウントを替えた先から見えない。`account_dir` の行の `local_only` に名前が出るので、 -共有したいものは共有の `~/.claude/` へ移す(次の起動で symlink になる)。登録を外しても(`account remove`)共有側は消えない。 - -| いつ | ラッパー | `supervise.py`(プランの claude -p) | -| --- | --- | --- | -| 起動 | `account use` の指定が使えればそれを使う。無ければ区間ごとに、上限に達していないアカウントのうち残りの量の最も大きいものを選ぶ。今のアカウントが閾値未満なら替えず、閾値を超えていても候補の残りの量が今以下なら替えない | 起動したときのアカウント(`NDF_CLAUDE_ACCOUNT`)で呼ぶ | -| 利用上限(5 時間・7 日・支出上限) | 子の応答が上限で終わると(`StopFailure` hook の `limit.json`)、次の区間を別のアカウントで `claude --resume <会話> "<最初の入力>"` として起動する。最初の入力は `ndf-next` があればそれ、無ければ未達の `/goal <条件>`、それも無ければ切り替えの理由に合う定型の文(例: `利用上限でアカウントを替えた。中断したところから続ける`)。**バックグラウンドの作業が残っている間は子を終えない** | 待たずに別のアカウントで同じ呼び出しをやり直す | -| 認証が通らなかった(401) | 子の応答が認証の失敗で終わると、登録済みアカウントならそのアカウントを 1 時間候補から外し、上限のときと同じ順(使える登録済みアカウント → 従量の接続の宣言)で次の区間を起動する(最初の入力は `認証が通らなかったためアカウントを替えた。中断したところから続ける`)。既定のログインでも同じく替え、登録が 1 つ以下でも替えた先を以後の区間で使い続ける。従量の接続の 401 では替えない。替え先が無ければ、ログインの形に合う直し方を 1 行出して子を残す(既定のログイン: claude の中で `/login`。登録済みアカウント: `relay.py account add <名前>`。従量の接続: `relay.py account check metered` で宣言を確かめる)。1 つの区間につき 1 度だけ | 結果が認証の失敗なら、そのアカウントを外して同じ呼び出しをやり直す | -| 使用率が閾値を超えた | 定期の確認(推論なし)が 1 行を出し、**次のカットポイントで**替える | — | -| すべて上限 | 宣言があれば従量の接続へ移る。無ければ 1 行を出して子を残す | 宣言があれば従量の接続へ移る。無ければ今と同じ上限待ち | -| 従量の接続で動いている間 | 定期の確認で上限の外れたアカウントを見つけたら、次のカットポイントで戻す | 起動のたびに戻れるかを確かめる | - -**従量の接続(Bedrock)は `account add-bedrock` で登録する。** AWS のプロファイルは先に `aws configure sso` などで -作っておく。候補(`aws configure list-profiles`)が 1 つなら確認だけ、複数なら番号で選ぶ。地域はプロファイルの `region`、 -モデルは Bedrock の推論プロファイルのうち Claude のものから選ぶ。保存の前に `aws sts get-caller-identity` と -`aws bedrock-runtime converse`(最大 1 トークン。1 回分の費用が掛かる)で呼べるかを確かめ、呼べなければ理由(`認証切れ`・ -`権限が無い`・`地域で使えない`・`モデルが有効でない`)と AWS のエラーの種類の名前を出して保存しない。macOS でも使える。 - -```bash -python3 ~/.claude/ndf/relay.py account add-bedrock # 端末で選ぶ -python3 ~/.claude/ndf/relay.py account add-bedrock --profile bedrock-dev --model <モデルの ID> --yes --json -python3 ~/.claude/ndf/relay.py account check metered # 今の宣言で呼べるかを確かめる -python3 ~/.claude/ndf/relay.py account remove metered # 保存した宣言を外す -``` - -宣言はアカウントの置き場の `metered.json`(0600)に保存し、次の起動から効く。シェルの設定ファイルは書き換えない。 -保存するのは変数の名前と値(`CLAUDE_CODE_USE_BEDROCK`・`AWS_PROFILE`・`AWS_REGION`・`ANTHROPIC_MODEL`)だけで、AWS の鍵は -`~/.aws/` に残る。保存した宣言で起動する子からは、環境の AWS の鍵の変数を外す。宣言は 1 つだけで、置き換えは確認 -(`--yes`)が要る。保存先が壊れていれば、ラッパーは区間 1 の起動で、`supervise.py` は最初の呼び出しの前に 1 行出し、 -宣言なしとして扱う。 - -**環境変数で上書きする場合は `NDF_SUPERVISE_CLAUDE_FALLBACK` を定義する**(`KEY=VALUE` を空白区切り。値はリポジトリに -書かない)。定義されていれば(空でも)保存した宣言は読まない。Bedrock 以外(API キーなど)もこの形で宣言する。 - -```bash -export NDF_SUPERVISE_CLAUDE_FALLBACK='CLAUDE_CODE_USE_BEDROCK=1 AWS_PROFILE=<名前> AWS_REGION=us-east-1' -``` - -従量の接続で起動する子にはアカウントの設定ディレクトリを渡さず、アカウントで起動する子には宣言の変数を渡さない(混ぜない)。記録と画面に -出るのは変数の名前だけである。**登録が 1 つ以下なら今と同じ**(ラッパーは上限で何もしない。`supervise.py` は宣言を -呼び出しごとに 1 度だけ試してから上限待ち)。切り替えに LLM は使わない。 - -| 設定 | 既定 | 意味 | -| --- | --- | --- | -| `NDF_ACCOUNT_CHECK_INTERVAL` | 300 | 使用量の取得先を 1 アカウントにつき何秒に 1 回まで呼ぶか。定期の確認の間隔も兼ねる | -| `NDF_ACCOUNT_SWITCH_AT` | 90 | 閾値(%)。100 以上なら閾値では替えない | - -替えるたびに画面へ `ndf-relay:` の 1 行(例: `利用上限(five_hour)に達したため、アカウントを work1 から work2(b@example.com)へ替えて続ける`) -が出て、`log.jsonl` に `account` の行が残る。 +claude のアカウントを 2 つ以上登録しておくと、利用上限で止まらずに別のアカウントで続く。登録・指定・切り替えの条件と設定は [relay-accounts.md](relay-accounts.md) にある。 ## カットポイントの引継ぎ文書と ndf-next はスクリプトで作る