Repository navigation
Description
何が起きたか
2026-10-31〜11-01(JST)、開発ループ l20261031 の周回 5 を、2 つの Claude Code のセッションが同時に conductor として回した。
| 時刻(UTC) | セッション A(中継で起動、2e7a9fee) | セッション B(/goal で再開) |
|---|---|---|
| 14:1x | 周回 5 を組んでキューを流す | loop start → loop select → loop sprint で同じ周回 5 を組み、キューを流す(loop start は resumed を返し、他の conductor がいることを示さない) |
| 17:01〜17:03 | 実装の未決 1 の残りを #2309 に起票 | 同じ残りを #2310 に起票(重複で閉じた) |
| 16:43〜17:33 | queue --resume(同じ done へ) |
queue --resume(同じ done へ)。#2291 の実装のプランを 2 つのプロセスが続きから流した |
| 17:32 | 検査を再開 | 検査が「別の実行 refactor-4f2ec4694cf6-1 が作業場所を使っている」で止まる |
B が A に気づいたのは、検査が止まった理由から作業場所を使っているプロセスを辿ったときである。それまでどちらも相手がいることを知る手段が無かった。B は操作をやめて見張りだけに切り替えた。
原因: conductor どうしの排他が無い
conductor が触る状態(開発ループの記録・スプリントの状態・キューの done・プランの状態ディレクトリ)は、どれも 1 つの conductor が持つ前提で作られているが、それを守る仕組みが無い。
| 状態 | 今の作り | 2 つ目の conductor に起きること |
|---|---|---|
開発ループの記録 .ndf/loop/<名>/loop.json |
状態(running・周回・進行中か)だけを持ち、どのセッションが conductor かを持たない |
loop start が resumed を返し、同じ周回へ入れる |
キューの done と queue --resume |
再開は done と各プランの progress.jsonl から続きを決める |
同じ done を 2 つのプロセスが再開し、同じプランのステップを二重に流す |
走っているプランの登録簿(supervise_lib/running.py) |
「queue のプロセスに 1 つあり、永続しない」 | 別のプロセスの queue が流すプランが見えず、触るファイルの除外も働かない |
作業場所の持ち主(lib/run_owner.py) |
cross-review・cross-refactoring の作業場所だけにある。寿命の窓は 24 時間 | ここで初めて衝突が見つかる。止まった後も 24 時間「使用中」が残りうる(#2285 ) |
開発ループに限らない。--loop を使わないスプリントでも、引継ぎ文書を読んだ 2 つのセッションが同じ queue --resume を打てば同じことが起きる。
何をするか
書き換える対象ごとに、書き換える間だけ取るロックを置く。 開発ループ全体を 1 本のロックにすると、conductor が同時に 1 つしか動けず、並列に強くする方針(2026-10-31)と逆向きになる。Issue の単位だけでは、別々の Issue でもぶつかる共有のもの(版数・同じファイル・同じブランチへのマージ・ループの選定)を防げない。実際に、並行のスプリント m26-1 と版 10.27.1 がぶつかり、手で 10.27.2 に書き換えた。
| 対象 | 持つ期間 | 種類 | 防ぐ衝突 |
|---|---|---|---|
| Issue | 着手から PR のマージまで | リース | 同じ Issue の二重の実装・同じ残りの二重の起票(今回の事故の大半) |
| プランの状態ディレクトリ・キューの done | 流している間 | リース | 同じプランのステップを 2 つのプロセスが流す(queue --resume の二重) |
| 触るファイル | プランが流れている間 | リース | 別々の Issue のプランが同じファイルを書き換える。今の登録簿(running.py)をプロセスをまたいで見えるものにする |
| 作業場所・ブランチ・PR | 使っている間 | リース | 同じ作業場所を 2 つのレビュー・リファクタリングが使う。今の持ち主の記録(run_owner.py)の寿命を短くする |
| 版数の採番・リリース・スプリントブランチや develop へのマージ | その操作の間だけ | 短いロック | 版数の重複・同時のマージ |
| 開発ループの選定・棚卸し・見直し | その操作の間だけ | 短いロック | 同じ Issue を 2 つの周回が選ぶ・順位の上書き |
- リース: ロックの中で宣言(セッションの識別子・pid とホスト・始めた時刻・最後に動いた時刻)を置く。担当は節目で最後に動いた時刻を更新し、終わったら解放する。担当のプロセスが生きていない(同じホスト)、または寿命を過ぎたら、別のセッションが引き取って続け、引き取りを記録に残す。中継の再起動で次のセッションが続くのは、前のセッションのリースを引き取る正規の流れになる
- 短いロック: ファイルのロック(
lib/locks.py)を操作の間だけ取る。取れなければ待つか断る - 断るときの出力: 担当のセッションと最後に動いた時刻を出し、引き取る手順を示す
- 取る順序を固定する(例: Issue → プラン → ファイル → 作業場所 → 短いロック)。複数のロックを取るときに互いに待ち合って止まるのを防ぐ
- 部品: 既存の
lib/run_owner.py(持ち主・最後に動いた時刻・終わった時刻・引き取り)とlib/locks.pyを広げて使い、同じ役割の部品を作らない。見直しのフェーズの担当(開発ワークフローに「見直し」と「大整理」を足す: 棚卸しを毎スプリントの差分の棚卸し・数回に 1 回の見直し・十数回に 1 回の大整理(根本原因の発見と組み直し、後回しの再評価)に分ける #2315 の考察 6)も「ループの選定・棚卸し・見直し」の行のロックで扱う - ループの記録の扱い: ループ全体のリースは置かない。2 つの conductor が同じループの別々の Issue を進めるのは許し、ぶつかる所だけを上の行で止める
ロックの置き場: ネットワークで共有できるロックにする
ファイルのロックでは足りない。 ファイルのロックが効くのは同じマシン(同じファイルシステム)の中だけである。Neural Development Factory では、conductor は実行ジョブのコンテナ(factory/runner/、ECS のタスク)の中で動き、スプリントを並列に回す。別々のコンテナの conductor は互いのファイルのロックも pid も見えない。手元でも、別のコンテナや別のマシンのセッションが同じリポジトリを進めれば同じである。
要件
| 要件 | 理由 |
|---|---|
| マシン・コンテナをまたいで 1 つに決まる(取得が原子的) | Factory のランナーどうし、手元と Factory の間で衝突を防ぐ |
| リース(期限つき)と更新・解放・引き取り | 担当が落ちたら、人を待たずに別の conductor が続けられる。pid の生死はマシンをまたいで見られないので、期限で判定する |
| フェンシングトークン(取るたびに増える番号) | 止まっていた古い担当が期限切れの後に動き出しても、書き込みの側が古い番号を断れる。期限だけでは、GC の停止や通信の遅れで 2 つの担当が同時に書く瞬間が残る |
| 手元だけの利用者も使える | NDF はプラグインとして AWS を持たない利用者にも配る。ネットワークのロックが無い環境でも同じ契約で動く |
plugins/ は factory/ を参照しない |
境界の検査(scripts/check-service-boundary.py)がある。NDF の側は契約とつなぎ先の宣言だけを持つ |
候補
| 候補 | 中身 | 得失 |
|---|---|---|
| A. DynamoDB の条件付き書き込み + TTL | 項目の作成・更新を ConditionExpression で原子的に行い、版の番号をフェンシングトークンにする。AWS 公式の Java のロックの実装(DynamoDB Lock Client)と同じ形 |
Factory はすでに DynamoDB を使い、条件付き書き込みも 38 か所ある。強い一貫性。手元の利用者には AWS の資格情報が要る |
| B. Factory の API にロックの経路を置き、裏を A にする | ランナーは実行ジョブの token で、手元の利用者は Factory のアカウントで、HTTP のロックの API(取る・更新・解放・状態)を呼ぶ | NDF の側は HTTP の契約だけを知り、AWS に依存しない。Factory を使わない利用者は使えない |
C. GitHub をロックの置き場にする(refs/ndf-locks/<対象> の作成・期待する oid つきの更新) |
GitHub の ref の作成は既にあれば失敗するので原子的。更新に「前の oid」を条件として付けられれば比較して交換できる | 追加のインフラが要らず、手元でも Factory でも同じ。API の回数の制限と遅延、期限の判定を自前で書く。期待する oid つきの更新が原子的かは実行して確かめる |
| D. etcd・Consul・Redis(Redlock) | 専用の調整役 | 新しいインフラを運用する。Redlock は安全性に議論がある。今の構成に無い |
推奨
NDF の側にロックの契約(取る・更新・解放・状態・フェンシングトークン)を 1 つ置き、つなぎ先を .ndf/ の設定で選べるようにする。
| 環境 | つなぎ先 |
|---|---|
| Factory のランナー | B(Factory のロックの API。裏は A の DynamoDB) |
| 手元で、複数のマシン・コンテナから同じリポジトリを進める | B(Factory に接続していれば)か C(GitHub) |
| 手元の 1 台だけ | ファイルのロック(lib/locks.py)+ 期限。今の部品を同じ契約の後ろに置く |
- 書き込みの側はフェンシングトークンを確かめる。ロックのサービスが持つ状態(版数の採番・ループの選定)はサービスの中で番号を照らす。git への push は期待するコミットを条件にして(
--force-with-leaseと同じ考え)、古い担当の上書きを断る - 既存の部品(
lib/run_owner.py・lib/locks.py)は、手元の 1 台のつなぎ先として契約の後ろへ移す。同じ役割の部品を 2 つ持たない - Factory の側のロックの API と DynamoDB の表は Factory の課題として分け、
contracts/に契約を置く
受け入れ条件
- 2 つ目のセッションが、別のセッションが着手中の Issue に着手しようとすると断られ、担当と最後に動いた時刻が示される。着手中でない Issue には着手できる
- 同じ done へ 2 つ目のプロセスが
queue --resume(または同じプランを含むqueue)を打つと断られ、同じプランのステップが二重に流れない - 別々の Issue のプランが同じファイルを触るとき、別のプロセスの queue が流すプランでも重なりが見え、同時には書き換えない
- 版数の採番・リリース・スプリントブランチや develop へのマージ・ループの選定と棚卸しは、2 つのセッションから同時に打っても 1 つずつ進む。版数が重ならない
- 別々のマシン・コンテナの conductor が同時に同じリースを取ろうとしても、片方だけが取れる。Factory のランナー 2 つで示す
- 期限が切れた古い担当が動き出して書き込もうとすると、フェンシングトークンが古いため断られる
- ネットワークのロックのつなぎ先が無い環境(手元の 1 台)でも、同じ契約でファイルのロックへ落ちて動く
- 2 つのプロセスが同時に同じリースを取ろうとしても、片方だけが取れることをテストで示す
- 担当のプロセスが終わっている、または寿命を過ぎていれば、別のセッションが引き取って続けられ、引き取りが記録に残る
- 中継の再起動(
ndf-next→ 次のセッション)では、前のセッションの宣言を引き取って続く - 複数のロックを取る経路が決めた順序で取り、待ち合って止まらない
loop statusとsupervise.pyの状態の表示が、担当のセッションと最後に動いた時刻を出す
未決
| 項目 | 決め方 |
|---|---|
| 対象の一覧と取る順序の確定(上の表で足りるか) | 設計で決める |
寿命と生きている印の間隔。長い wait の間(数時間)をどう生きている扱いにするか |
設計で決める |
| ロックの置き場(推奨: 契約を 1 つにし、Factory は API + DynamoDB、手元の複数マシンは Factory か GitHub、1 台はファイル) | 利用者(承認ゲート 1) |
| GitHub の ref で期待する oid つきの更新が原子的に働くか | 設計で実行して確かめる |
関連
- 見直しのフェーズの担当を 1 つにする: 開発ワークフローに「見直し」と「大整理」を足す: 棚卸しを毎スプリントの差分の棚卸し・数回に 1 回の見直し・十数回に 1 回の大整理(根本原因の発見と組み直し、後回しの再評価)に分ける #2315
- 作業場所が「使用中」のまま残る: リファクタリングの工程で着手前のテストが時間切れになると、作業場所が「使用中」のまま残り、テストのプロセスも残る #2285
- 開発ループの確定仕様:
docs/specifications/ndf-workflow-development-loop.md - 走っているプランの登録簿:
plugins/ndf/scripts/supervise_lib/running.py
Activity
[-]開発ループを 2 つのセッションが同時に回せてしまう: 記録が conductor を持たず、loop start が 2 つ目のセッションにも resumed を返す[/-][+]conductor どうしの排他ロックが無く、同じ開発ループ・同じキューを 2 つのセッションが同時に進められる[/+][-]conductor どうしの排他ロックが無く、同じ開発ループ・同じキューを 2 つのセッションが同時に進められる[/-][+]conductor どうしの排他が無い: Issue・プラン・ファイル・版数などの対象ごとにロックを置き、並列の conductor が同じ物を同時に書き換えないようにする[/+]Docs: #2342 の設計の範囲を #2321・#2316・#2325 の 3 件に絞る