Skip to content

fix: セッション自身が作った PR の状態を番号で追跡する - #120

Merged
takecchi merged 4 commits into
mainfrom
fix/pr-status-for-session-opened-prs
Aug 12, 2026
Merged

fix: セッション自身が作った PR の状態を番号で追跡する#120
takecchi merged 4 commits into
mainfrom
fix/pr-status-for-session-opened-prs

Conversation

@takecchi

Copy link
Copy Markdown
Owner

症状(報告)

いつまで経ってもPRの状況(完了等)が一覧に表示されないケースがあります。本来であれば読み込み中の表示になっていると思うんですが、それすらないです。

原因

PR の問い合わせがブランチ名だけを鍵にしていたため。

セッションが自分で gh pr create した PR(extraPrs)の head は使い捨ての feat/… などで、worktree に checkout されていないことが多い。すると gh pr view <branch> は毎回「PR なし」と答え、それは absent(権威ある答え)として扱われるので

  1. prStatus が永久に埋まらない → グリフ( マージ済み / / / )が付かない
  2. 一度 "answered" 済みなので (確認中)も二度と出ない
  3. 番号は extraPrs から描かれるので、裸の #109 だけが残る

実データで確認(報告者の state.json):

セッション pr extraPrs worktree HEAD 実際の PR
109 なし [109] codiva/agent-codex-model-claude-codex-claude #109(head は codiva/agent-codex-model-catalog-displayMERGED

#109 は実際にはマージ済みだが、その head ブランチはどの worktree にも無いので、ブランチ名で聞ける限り永久に解決できなかった。

修正

  • 番号で聞けるようにした: PrLookupOptions.knownPr を追加し、ブランチ候補(HEAD → 記録ブランチ)が全滅したら gh pr view <番号> で問い合わせる(gh pr view は番号も受ける)。番号は最後に試す — セッションブランチに新しく出た PR を古い番号で上書きしないため。
  • 渡す番号は primaryPr(= pr ?? extraPrs.at(-1))。解決できた PR は pr に昇格し、reducer が extraPrs から畳むのでグリフと +n が整合する。
  • バッチ(gh pr list)側も同じ: 既知 PR が突き合わせに失敗したら番号で確認する。従来は「ページが truncated のときだけ」だったので、head ブランチが見えない PR は永久に absent だった(truncated の判定自体が不要になった)。
  • ready 化は番号でgh pr ready <番号>)。解決した PR がセッションブランチのものとは限らないので、state.branch で ready にすると失敗するか別の PR を触る。
  • 「分からない」を可視化: 番号が分かっていても状態が未取得/取得失敗なら、グリフの位置に / ? を出す(⋯ #12 / ? #12)。裸の番号は「状態が無い=健全」と区別が付かない。併せて、番号だけ分かっている行(復元直後)も初回ポーリング前は loading にする。

API コストは増やしていない: 追加の gh pr view が走るのは「PR があると分かっていて、どのブランチでも解決できなかった」行だけで、その行の再取得間隔は 60〜180 秒。マージ済みになれば以後問い合わせない。

テスト計画

  • npm run lint
  • npm run typecheck
  • npm test(2630 passed。utils/pr.spec.ts / core/pr-coordinator.spec.ts に番号フォールバックのケース、新規 src/ui/pr-cell.spec.tsx⋯ #12 / ? #12 の表示ケースを追加)
  • npm run build
  • 手動確認: 実セッションで「自分で別ブランチを切って PR を出したセッション」の行にグリフが付くこと(TTY + 認証が必要なため報告者に依頼)

補足(このPRには含めない)

調査中に見つけた別の弱点: utils/pr.tsgh 呼び出しにタイムアウトが無くPrCoordinator.refreshPrs の多重実行ガード(refreshing)にも期限が無い。スリープ復帰などで gh が 1 回ハングすると、以後 PR 情報がプロセス寿命のあいだ一切更新されなくなる(新しいセッションは すら出ない)。別 PR で直すのが良さそうです。

一覧の PR セルに `#109` とだけ出て、マージ済み/CI の状態(グリフ)も
「確認中(⋯)」「確認できず(?)」も**一生表示されない**ケースがあった。

原因は問い合わせがブランチ名だけを鍵にしていたこと。セッションが自分で
`gh pr create` した PR(`extraPrs`)の head は使い捨ての `feat/…` で、
worktree に checkout されていないことが多い。すると `gh pr view <branch>`
は毎回「PR なし」と答え、`absent`(= 権威ある答え)として扱われるので
`prStatus` は永久に埋まらず、`answered` 済みなので読み込み中の印も二度と
出ない。番号は `extraPrs` 由来で描かれるため、番号だけが裸で並び続ける。

- `lookupPr` に `PrLookupOptions.knownPr` を追加し、ブランチ候補が全滅したら
  `gh pr view <番号>` で聞く(`gh pr view` は番号も受ける)。番号は**最後**に
  試す — セッションブランチに新しく出た PR を古い番号で上書きしないため。
- 渡す番号は `primaryPr`(= `pr ?? extraPrs.at(-1)`)。解決できた PR は
  `pr` に昇格し、reducer が `extraPrs` から畳むのでグリフ・`+n` が整合する。
- バッチ(`gh pr list`)側も、既知 PR が突き合わせに失敗したら番号で確認する。
  従来は「ページが truncated のときだけ」だったので、head ブランチが見えない
  PR は永久に `absent` になっていた(`truncated` の判定自体が不要になった)。
- ready 化は `gh pr ready <番号>`。解決した PR がセッションブランチのものとは
  限らないので、`state.branch` で ready にすると失敗するか別の PR を触る。
- 番号が分かっていても状態が未取得/取得失敗なら、グリフの位置に `⋯` / `?` を
  出す(`⋯ #12` / `? #12`)。裸の番号は「状態が無い=健全」と区別が付かない。
  併せて、番号だけ分かっている行(復元直後)も初回ポーリング前は `loading`。
@takecchi
takecchi marked this pull request as ready for review August 11, 2026 16:06
@takecchi takecchi closed this Aug 11, 2026
@takecchi
takecchi deleted the fix/pr-status-for-session-opened-prs branch August 11, 2026 20:17
@takecchi
takecchi restored the fix/pr-status-for-session-opened-prs branch August 11, 2026 20:17
@takecchi takecchi reopened this Aug 11, 2026

@takecchi takecchi left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1件、修正が必要な問題があります。

[High] 別リポジトリで作成した PR を、現在のリポジトリの同番号 PR と取り違えます。

extraPrs の検出は https://<host>/<owner>/<repo>/pull/<number> を受理しており、pr-detect.ts のコメントでも GitHub Enterprise を含めホストや owner/repo を固定しない設計です。そのため、セッションが gh pr create -R owner/other などで別リポジトリに作った PR も正しく PrRef.url として保存されます。

しかし今回の knownPrOf() / PrLookupOptions.knownPr は URL を捨てて番号だけを渡し、lookupPr() は worktree の cwd で gh pr view <番号> を実行します。PR番号はリポジトリごとの番号なので、例えば保存された参照が https://github.com/acme/other/pull/42 でも、現在の codiva リポジトリに #42 が存在すればそちらを found として採用し、元の URL・状態を無関係な PR で上書きします。さらにその PR が draft かつ checks passing なら、applyResult()gh pr ready 42 を同じ cwd で実行し、無関係な現在リポジトリの #42 を ready 化します。現在側に同番号が無い場合も、別リポジトリの既知 PR を absent として消してしまいます。

knownPr を番号ではなく PrRef(少なくとも repo identity を含む値)として渡し、フォールバックの gh pr view と auto-ready の両方を完全な pr.url で実行するのが安全です。次の回帰ケースも必要です。

  • worktree の repo A に #42 があり、extraPrs は repo B の /pull/42 を指す
  • repo B の URLを問い合わせ、repo A の #42を採用/ready化しない
  • repo A に #42がなくても repo B の参照を absent として消さない

確認結果: 変更対象の utils/pr.spec.tscore/pr-coordinator.spec.tsui/pr-cell.spec.tsx は計110件成功し、GitHub CIも成功しています。

レビュー指摘(別リポジトリの PR を取り違える)の修正。

`extraPrs` は `https://<host>/<owner>/<repo>/pull/<n>` を受理するので、
セッションが `gh pr create -R owner/other` で**別リポジトリ**に作った PR も
保存される。ところが `knownPr` を番号で渡していたため、worktree の cwd で
`gh pr view 42` を実行すると**現在リポジトリの #42** が返る:

- 無関係な PR の状態・URL で `pr` を上書きする
- それが draft かつ checks passing なら `gh pr ready 42` で無関係な PR を
  ready 化する
- 現在リポジトリに同番号が無ければ、既知 PR を `absent` として消す

`PrLookupOptions.knownPr` / `PrLookupTarget.knownPr` を `number` から
`PrRef` に変え、フォールバックの `gh pr view` と auto-ready の両方を
**PR の URL** で実行する(`gh` は URL でリポジトリごと解決する)。
回帰テストは utils / core の両方に追加:

- 単体: 既知 PR が別リポジトリの `/pull/42` でも、その URL で問い合わせ、
  **番号(`42`)では一度も聞かない**
- バッチ: 現在リポジトリの `pr list` に同番号 #42 の行があっても、
  cross-repo の既知 PR はその URL で確認し、行に引きずられない
- coordinator: `markReady` が解決した PR の URL で呼ばれる
@takecchi

Copy link
Copy Markdown
Owner Author

ご指摘のとおりでした。番号ではなく PR の URL で問い合わせるように直しました(bae0b1b)。

extraPrshttps://<host>/<owner>/<repo>/pull/<n> を受理するので別リポジトリの PR も入り得るのに、knownPr を番号で渡していたため worktree の cwd で gh pr view 42現在リポジトリの #42 が返る、というのは完全に取り違えでした。指摘いただいた 3 つの帰結(無関係な PR での上書き / 無関係な PR の ready 化 / 同番号が無ければ absent で消える)すべて成立します。

変更

  • PrLookupOptions.knownPr / PrLookupTarget.knownPrnumberPrRef(number + url)に変更
  • フォールバックの候補は String(number) ではなく knownPr.urlgh pr view <URL> はリポジトリごと解決する)
  • auto-ready も String(pr.number)pr.urlgh pr ready <URL>
  • 型・コメント・.claude/rules/git-and-io.md / docs/ARCHITECTURE.md / README.md に「番号はリポジトリごとなので URL で指定する」理由を明記

追加した回帰テスト

src/utils/pr.spec.ts

src/core/pr-coordinator.spec.ts

  • readies the PR it resolved by URL, even in another repositorymarkReadyhttps://github.com/acme/other/pull/42 で呼ばれる
  • knownPr を渡す既存ケースを PrRef 期待値に更新(バッチの seen 検証含む)

確認

lint / typecheck / test(2633 passed)/ build 済み。

@takecchi takecchi left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

再レビューで、前回指摘した別リポジトリの同番号PR取り違えは修正されていることを確認しました。PrRef全体をlookupへ渡し、問い合わせとauto-readyの双方でURLを使う実装になっており、cross-repoの回帰テストも追加されています。変更対象を含む既存テスト165件は成功しました。

そのうえで、別の修正が必要な問題が1件あります。

[Medium] URLで確認した extraPr が本当に absent だった場合、今回解消するはずの裸のPR番号表示が永久に残ります。

extraPrs だけに #109 がある状態で、ブランチ候補も既知URLも absent を返すと、PrCoordinator.applyResult()session.setPr(undefined) を呼びます。しかし pr reducer は event.prundefined のとき withoutPrRef(state.extraPrs, ref)ref = undefined を渡すため、extraPrs は削除されません。その結果:

  1. primaryPr(state) は引き続き extraPrs.at(-1)#109 を返す
  2. prStatusprLookup はどちらも undefined になる
  3. answered に入るので、以降 markLooking()loading を付けない
  4. PrCell はグリフなしの裸の #109 を描画する

つまり、PRが削除された/URLが権威的に存在しないケースでは、PR本文で説明されている元の症状(状態も / ? も付かない裸の番号)がそのまま残ります。再ポーリングしても同じ extraPr を既知URLとして問い合わせ続けるだけです。

PR head に extraPrs: [gone]、lookup結果 absent の回帰ケースを追加し、refresh後に参照が除去されることを期待すると、配列がそのまま残って失敗することを確認しました(同ファイルの既存49件は成功)。

既知URLまで absent と確認できた場合は、その PrRefextraPrs から除去できるイベント/Session APIを用意するか、少なくとも裸の番号にならない明示的な状態を保持する必要があります。ブランチにPRが無いだけの通常の absent と、既知URLも存在しない absent を区別できるようにすると扱いやすいと思います。

GitHub CIは最新commitで成功しています。

レビュー指摘([Medium] URL で確認した extraPr が本当に absent だと裸の PR
番号が永久に残る)の修正。

`extraPrs` だけに #109 があり、ブランチ候補も既知 URL も `absent` を返した
場合、`applyResult` は `setPr(undefined)` を呼ぶが `pr` reducer の
`withoutPrRef(state.extraPrs, ref)` は `ref === undefined` なので何も落ちない。
結果 `primaryPr` は #109 を返し続け、`prStatus`・`prLookup` はどちらも
undefined、`answered` 済みで `loading` も付かない = **このPRで直したはずの
「グリフも ⋯/? も無い裸の番号」に戻る**。再ポーリングしても同じ URL を
聞き続けるだけ。

- `CodivaEvent` に `pr_gone`(`PrRef`)を追加。reducer は `extraPrs` と
  `pr`/`prStatus` の**両方**から参照を落とす(`primaryPr` はどちらでも読むので
  片方だけでは裸の番号が残る)。持っていない参照なら同一参照を返す。
- `Session.dropPr(ref)` / `SessionHandle.dropPr` を追加。
- `PrCoordinator.applyResult` は「`absent` かつ `knownPr` を渡していた」ときだけ
  `dropPr` する。`absent` は全候補が答えたときにしか返らないので、これは
  GitHub が「その PR は存在しない」と答えた場合に限られる。`unavailable`
  (レート制限・オフライン)では流さない = 参照は保持したまま `?` を出す。

テスト:
- reducer: extraPrs から落とす / 唯一なら undefined に / 追跡中の pr と一致
  したら pr+prStatus も消す / 持っていない参照は no-op(同一参照)
- coordinator: 自作 PR が absent なら `dropPr` → `setPr(none)` の順で消える /
  `unavailable` では消さない / 既知 PR が無ければ `dropPr` を呼ばない
- app: 実 reducer 経由の統合ケース(一覧から `#109` が消える)
@takecchi

Copy link
Copy Markdown
Owner Author

ご指摘のとおりで、このPRが直したはずの症状にそのまま戻る穴でした。pr_gone イベントを追加して修正しました(f813ae9)。

原因の確認

extraPrs: [#109] だけの状態でブランチ候補も既知 URL も absent を返すと、applyResultsetPr(undefined) を呼びますが、pr reducer の withoutPrRef(state.extraPrs, ref)ref === undefined なので extraPrs は無傷。結果 primaryPr#109 を返し続け、prStatusprLookup はどちらも undefined、answered 済みで loading も付かない → グリフも /? も無い裸の #109 が永久に残る。おっしゃるとおりです。

修正

「ブランチに PR が無いだけの absent」と「既知 URL も存在しない absent」の区別は、呼び出し側が知っている情報knownPr を渡したかどうか)で付けました。lookupPr候補が答えたときだけ absent を返すので、knownPr を渡していて absent なら「GitHub がその PR は存在しないと答えた」と確定できます。

  • CodivaEventpr_gonePrRef を追加。reducer は extraPrspr/prStatus両方から参照を落とす(primaryPr はどちらでも読むので片方だけでは裸の番号が残る)。持っていない参照なら同一参照を返す
  • Session.dropPr(ref) / SessionHandle.dropPr を追加
  • PrCoordinator.applyResultabsent かつ knownPr を渡していたときだけ dropPr → その後 setPr(undefined)unavailable(レート制限・オフライン)では流さないので、参照は保持したまま ? が出る従来の挙動のまま

追加テスト

  • status-reducer.spec.ts: extraPrs から落とす / 唯一なら undefined に / 追跡中の pr と一致したら pr+prStatus も消す / 持っていない参照は no-op(同一参照)
  • pr-coordinator.spec.ts: ご提示のケース(extraPrs: [gone] + absent['prLookup:loading', 'dropPr:#109', 'setPr:none'] で参照が消える)/ unavailable では消さない / 既知 PR が無ければ dropPr を呼ばない
  • tests/app.test.tsx: 実 reducer を通した統合ケース(一覧から #109 が消え、extraPrsundefined になる)
  • 既存の「absent は権威ある答え」ケースも dropPr:#42 を含む期待値に更新(同じ理屈で参照も落ちるのが正しいため)

確認

lint / typecheck / test(2641 passed)/ build 済み。

@takecchi takecchi left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

再レビューで、前回指摘した「absentになった extraPr が裸の番号として永久に残る」問題は、pr_gonepr / extraPrs の両方から参照を除去する形に修正されていることを確認しました。unavailable では削除しないテストと、実UIから番号が消える統合テストも追加されています。

そのうえで、複数PRの切り替わりに1件問題があります。

[Medium] 消えた追跡PRの次に extraPr が残っていると、新しい代表が次回ポーリングまで裸の番号になります。

例えば pr = #108extraPrs = [#109] の状態で、#108のURLまで含めたlookupが absent になると、今回の処理は dropPr(#108)setPr(undefined) と進みます。これにより #109primaryPr() の新しい代表へ即座に繰り上がりますが、prLookupundefined のままです。同時に answeredlastFetched は今回の#108への回答で更新されるため、markLooking() はもう走らず、#109をその場で問い合わせ直すこともありません。

結果として、今回避けたい「状態も / ? もない裸の #109」が次の更新まで表示されます。次回は state.pr が無い扱いなので、完了セッションでは最大60秒、稼働中では最大180秒待ちます。消失した参照が複数続く場合は、この待ちが1件ごとに繰り返されます。

PR head に次の回帰ケースを追加して再現しました。

  • pr: #108(statusあり)と extraPrs: [#109] を持たせる
  • lookupを absent にする
  • refresh後、#109が残るなら prLookup === "loading" を期待する

実際は prLookup === undefined となり、追加テストが失敗しました(同ファイルの既存52件は成功)。

参照を落とした後にも別の primaryPr が残る場合は、その場で次の既知URLを解決するか、少なくとも prLookup: "loading" にして lastFetched / answered を新代表向けにリセットする必要があります。新代表が未取得なのに、旧代表への回答をそのままキャッシュ済みとして扱わないことが重要です。

確認結果:

  • 変更対象を含む既存5ファイルのテストと npm run typecheck: 成功
  • GitHub CI check: 成功

レビュー指摘([Medium] 消えた追跡 PR の次に extraPr が残ると、新しい代表が
次回ポーリングまで裸の番号になる)の修正。

`pr = #108` / `extraPrs = [#109]` で #108 が absent だと `dropPr(#108)` →
`setPr(undefined)` で #109 が即座に `primaryPr` へ繰り上がるが、`prLookup` は
undefined のままで、`answered` / `lastFetched` は #108 への回答で更新済み。
その結果 `markLooking()` も走らず、#109 は次の陳腐化(完了 60 秒 / 稼働中
180 秒)まで「状態も ⋯/? も無い裸の番号」で表示される。参照が複数続けば
1 件ごとに繰り返す。

`applyResult` は参照を落としたあとに `primaryPr` がまだ残っていれば、
その行を回答済みにせず(`answered` / `lastFetched` を更新せず)
`prLookup: 'loading'` を立てて抜ける。今回の答えは落とした PR のもので
新代表については何も言っていないため、キャッシュ扱いにしてはいけない。
`lastFetched` が無いので次の tick(20 秒)で新代表を問い合わせ、それまでは
`⋯` が出る。

テスト: 指摘のケース(`['dropPr:#108','setPr:none','prLookup:loading']` →
続く refresh で #109 を knownPr として問い合わせ、status が付いて loading が
消える)を追加。
@takecchi

Copy link
Copy Markdown
Owner Author

ご指摘のとおりでした。修正しました(2475a55)。

原因

dropPr(#108)setPr(undefined)#109 が即座に primaryPr へ繰り上がるのに、answered / lastFetched落とした #108 への回答で更新済みだったため:

  • prLookupundefinedsetPr がクリアする)
  • markLooking()answered 済みで走らない
  • isPrRefreshDuelastFetched があるので陳腐化(完了 60 秒 / 稼働中 180 秒)まで待つ

→ 新代表 #109 が「状態も /? も無い裸の番号」で表示される。参照が複数続けば1件ごとに繰り返す。まさに直したかった状態でした。

修正

applyResult は参照を落としたあとに primaryPr がまだ残っていれば、その行を回答済みにせずanswered / lastFetched を更新せず)prLookup: 'loading' を立てて抜けます。

if (!pr && known && primaryPr(session.getState())) {
  session.setPrLookup('loading');
  return undefined;   // キャッシュに刻まない
}
this.answered.add(id);
this.lastFetched.set(id, this.now());

今回の答えは落とした PR のもので新代表については何も言っていないので、キャッシュ扱いにしないのが要点です。lastFetched が無いため次の tick(20 秒)で新代表を問い合わせ、それまでは ⋯ #109 が出ます(裸の番号にならない)。参照が連鎖して消える場合も 1 hop 20 秒で、どの瞬間も が付いた状態で進みます。

追加テスト

pr-coordinator.spec.ts にご提示のケースを追加:

  • pr: #108(status あり)+ extraPrs: [#109]、lookup は absent(ただし knownPrfix: agentに応じたモデル選択と表示を修正 #109 の URL なら found)
  • 1 回目の refresh → ['dropPr:#108', 'setPr:none', 'prLookup:loading']extraPrs[#109] のまま、prLookup === 'loading'
  • 時計を進めずに 2 回目の refresh → knownPr: #109 で問い合わせ、pr === #109 / checks: 'passing' / prLookup === undefined(= 落とした PR の freshness に引きずられない)

確認

lint / typecheck / test(2642 passed)/ build 済み。

@takecchi takecchi left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

再レビュー完了です。追加の修正指摘はありません。

前回指摘した「消えた追跡PRの次に extraPr が残ると、新代表が次回ポーリングまで裸の番号になる」問題は、次の処理で解消されていることを確認しました。

  • 消えた旧代表を dropPr / setPr(undefined) で除去
  • 次の primaryPr が残る場合は、旧代表への回答を answered / lastFetched に記録しない
  • 新代表には prLookup: "loading" を設定して裸の番号を防ぐ
  • 次のrefreshで新代表のURLを knownPr として問い合わせ、取得後にloadingを解除

この構造なら、消失した参照が複数連続する場合も1件ずつloadingを維持しながら次へ進めます。参照が尽きた場合は通常のanswered状態になり、unavailableの場合は既知参照を削除しない点も維持されています。

確認結果:

  • PR用worktreeで変更対象を含む関連5ファイルのテスト: 成功
  • npm run typecheck: 成功
  • GitHub CI check: 成功

これまで指摘したcross-repoの同番号取り違え、存在しない extraPr の残留、代表繰り上がり時の裸番号について、いずれも回帰テスト付きで修正されています。

@takecchi
takecchi merged commit 0180737 into main Aug 12, 2026
1 check passed
@takecchi
takecchi deleted the fix/pr-status-for-session-opened-prs branch August 12, 2026 02:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant