「Your pull request has been closed」

2026-04-26 早上打開 GitHub,看到一封 12 天前送出的 PR 來自 maintainer 的最新通知:

Your pull request has been closed.
State: CLOSED
mergedAt: null

第一反應是嘆口氣——卡了 6 輪 bot review,最後還是被關了。準備重新開一個 issue 解釋為什麼這個方向是對的。

但點進去看留言才發現完全不是那回事。


12 天前發生了什麼

04-14 半夜我在用自己跑在 VPS 上的 AI agent,那個 agent 走 OpenClaw 串接 Discord。每個 Discord thread 可以綁一個 ACP(Agent Client Protocol)session 跟 Claude/Codex/Gemini 等 CLI agent 對話。

用到一半發現一個怪現象:在綁定 thread 內打 /acp close,原本應該關掉 ACP session,但卻被 ACP agent 當成「請求」吃掉,回了一段莫名其妙的對話。/status/unfocus 也一樣。

trace 進 OpenClaw commands-acp.ts 發現 dispatch 路由跳過了 handleAcpCommand:在 ACP-bound 的 conversation 裡,所有 text 都被當 prompt 送給 ACP harness,連 /acp 開頭的管理指令都不例外。

當下一氣之下做了三件事:

  1. 03:14 開 issue #66298
  2. 07:46 fork → 寫修補 → 開 PR #66407
  3. 走完整 CONTRIBUTING checklist:pnpm tsgo / pnpm check / pnpm build / targeted pnpm vitest 全綠

PR diff 不大:2 個檔,+138/-13。在 commands-acp.ts 的 dispatch 點加 bypass,/acp 開頭的 text 不再被吞進 ACP harness、轉回 Gateway 命令處理路徑。


6 輪 bot review,最後卡在 scope-out

OpenClaw 跑 Codex Review + Greptile bot,每次 push 都會自動重 review。前 5 輪都是常見的 P1/P2(path normalize、case 處理、regression coverage、bypass 跟 handler 同步),改完都收。

真正卡住的是第 6 輪:Codex 抓到 commands-acp.ts:90 一個 pre-existing startsWith bug——這個 bug 跟 PR 主題相關但 scope 之外(在更下層的 normalizeCommandBody,應該另開 issue)。我留言 scope-out 貼了 trace 連結 discussion_r3078655259這條 unresolved 之後 maintainer 沒回應,狀態維持了 12 天

中間還發生兩件事:

  • 04-18:第三方 joeia26 開了 sibling PR #68617 解同一個 bug,diff +47/-2 比我小很多
  • 04-21:openclaw 的 prtags-bot 把這兩個 PR 列為 great-loon-t2te duplicate group

看到 sibling PR 有點挫敗——會不會別人先被 merge?但他也卡著沒動。


04-26 凌晨那 5 分鐘

對照 timestamp 看(GitHub API 都精準到秒):

01:58:19  steipete 推 commit a6d9926d1d 上 main
02:02:46  Issue #66298 closed (stateReason: COMPLETED)
02:02:53  PR #66407 closed (mergedAt: null)         ← 我這個
02:02:59  PR #68617 closed (mergedAt: null)         ← sibling

maintainer 先 commit 上 main,4 分鐘後回頭把 issue + 兩個 PR 一次關掉。三個 close 動作差不到 13 秒。

關 PR 的留言是這樣寫的:

Fixed on main in https://github.com/openclaw/openclaw/commit/a6d9926d1d, following this PR’s direction and adding the missing commands.text=false escape-hatch coverage.

The landed fix keeps /acp ... local in ACP-bound conversations, keeps /acp close working even when text commands are disabled, preserves the existing auth/scope checks, and also documents the bound-session command routing rule. Verified with pnpm check:changed.

Thanks @kindomLee; the changelog credits your work here. Closing this as superseded by the main-branch fix.

CHANGELOG.md line 482,在 ## 2026.4.25 → ### Fixes 段:

- ACP: keep `/acp` management commands, plus local `/status` and `/unfocus`,
  on the Gateway path inside ACP-bound threads so they are not consumed as
  ACP prompt text. Fixes #66298. Thanks @kindomLee.

這是「形式上 closed superseded、實質上 fix landed + 名字進 changelog」的 outcome。


maintainer 做的事跟我做的事

我的 PR:2 檔 / +138/-13。在 commands-acp.ts 的 dispatch 點 inline 修。

maintainer 的 commit a6d9926d1d9 檔 / +199/-8

檔案用途
CHANGELOG.md+3 行(我的 credit 在這)
docs/tools/acp-agents.md+6 行(新文件)
docs/tools/slash-commands.md+5 行(新文件)
src/auto-reply/reply/commands-acp.ts+1 行(主修點)
src/auto-reply/reply/dispatch-acp-command-bypass.ts+16 行(抽成獨立 module
src/auto-reply/reply/dispatch-acp-command-bypass.test.ts+128 行(regression test)
src/auto-reply/reply/commands-acp.test.ts+18 行
scripts/test-projects.test-support.mjs + test/scripts/test-projects.test.ts+22 行(測試 infra)

兩件事值得注意:

  1. 方向完全一樣:把 /acp 開頭 text 從 ACP harness 路由抽回 Gateway 命令路徑——maintainer 留言裡寫的 “following this PR’s direction” 不是客套
  2. scope 擴大:我只處理 /acp,他擴到 /status /unfocus 也本地化、且加上 commands.text=false 的 escape hatch(即使 text commands 整個被關,/acp close 仍能逃生)
  3. 架構升級:我 inline 在 commands-acp.ts 內改;他抽成獨立 dispatch-acp-command-bypass.ts module + 128 行 regression test + 兩份 docs

第 6 輪卡住的那個 P2 scope-out 議題(pre-existing startsWith bug),他在新 module 裡乾脆繞過去了,不需要碰原本那條 path。


closed without merge ≠ rejected(至少這次不是)

從 GitHub UI 看:state = CLOSED、mergedAt = null、頁面頂端紅色「Closed」badge。看起來就是被拒絕。

但同時:issue stateReason = COMPLETED(不是 NOT_PLANNED)、CHANGELOG 寫了 credit、commit message 說 “following this PR’s direction”。closedByPullRequestsReferences 也是空的——因為不是被另一個 PR merge 關掉,是 maintainer direct commit 上 main 後手動關。

這次的 outcome 形狀大概是:maintainer 吸收 fix direction、重寫成更乾淨的架構(抽 module / 加大量測試 / 加 docs)後 commit 上 main、回頭把原 PR close 為「superseded」。

我不知道這在 OSS 是不是常見 pattern——這是我第一次貢獻大專案,樣本數 1。但至少這次,PR 作者第一眼看到「closed, mergedAt: null」就下「我的 PR 被否決了」結論的話,會錯過真實 outcome。


下次再看到 closed/mergedAt=null 之前,跑這幾步

不下結論之前,先收集資料:

# 1. close 留言:maintainer 通常會說明 next steps 或 superseded path
gh pr view <n> --comments

# 2. 連動 issue 的 stateReason 才是真實意圖(COMPLETED ✅ vs NOT_PLANNED ❌)
gh issue view <issue_number> --json state,stateReason,closedAt

# 3. 看 close 時間附近 main 的相關 commit
gh api "repos/<owner>/<repo>/commits?since=<close_date_minus_1d>" \
    --jq '.[].commit.message' | head -20

# 4. release notes / CHANGELOG 看有沒有 credit
grep -i "<your_username>" CHANGELOG.md
gh release list --repo <owner>/<repo> --limit 3

最後一招特別有用:grep -i "<your_username>" CHANGELOG.md 跟看最新幾個 release notes。maintainer 採用你方向的話,常見會用 Thanks @<username> 形式 credit。沒被 credit 跟有被 credit 是兩種完全不同的 outcome。


收尾

這 12 天我做的事:

  • 開 issue + PR(4.5 小時,bug trace 跟 fix 都找對了方向)
  • 走完 6 輪 bot review(耐性消耗最大的部分)
  • 卡在 scope-out 第 6 輪 12 天無人回應(這段最痛)
  • 04-26 凌晨醒來看通知以為被 reject

最後得到的:fix 在 v2026.4.25 release 上線、CHANGELOG 名字、ACP-bound thread 內三個指令(/acp /status /unfocus)正式不會再被 ACP harness 吞掉。

對我來說的 takeaway:「貢獻」這次不是 PR commit hash 出現在 git log 裡,是 maintainer 用我的 direction 寫得更好、回頭把 credit 給我。 形式上不漂亮,實質上 ship 了我做不到的 scope(/status /unfocus 一起本地化)跟 test coverage。

這條 lesson 寫進自己的 LEARNINGS:closed, mergedAt: null 是訊號不是結論,去查 close 留言 + issue stateReason + main commit + CHANGELOG 四件事再下判斷。