Files
claude-code/src/commands/autofix-pr/prFetch.ts
Dosion 9d17597e58 feat(autofix-pr): 完整完成回流机制 (latent bug fix + completionChecker + 内容回流) (#1240)
* fix(autofix-pr): 修复 taskId 不一致导致 monitor lock dangling

问题:createAutofixTeammate 生成 teammate UUID 作为 monitor lock 的 key,
但 registerRemoteAgentTask 内部生成的 framework taskId 是另一个 UUID。
CCR session 自然完成时框架调 clearActiveMonitor(frameworkTaskId)
guard 失败,lock 永不释放,导致后续 /autofix-pr 报 "already monitoring"。

修复(Phase 1 of remote-agent completion loop):
- monitorState 新增 updateActiveMonitor(partial) 原子更新
- callAutofixPr 在 register 后 swap lock 的 taskId 到 framework 分配的 id
- RemoteAgentTask 引入 registerCompletionHook 注册式 API(参考已有的
  registerCompletionChecker 模式),在 5 个完成路径调 runCompletionHook
- autofix-pr 命令模块自己注册 cleanup hook,避免 framework 反向依赖
  command 模块

测试:
- monitorState 新增 4 个测试(updateActiveMonitor 行为 + bug 复现/修复)
- launchAutofixPr 新增 3 个端到端回归测试(taskId swap + hook 触发 +
  subsequent launch 不报 already monitoring)

完整分析与 Phase 2/3 改造方案见
docs/features/remote-agent-completion-analysis.md。

* feat(autofix-pr): 注册 completionChecker 用 gh CLI 探测 PR 完成

Phase 2 of remote-agent completion loop。Phase 1 修了 monitor lock
dangling,但完成信号仍然只能等 CCR session 自然 archive(timing 不可
预测,且不知道 PR 究竟有没有被修好)。Phase 2 加上主动完成探测。

实现:
- 新增 prOutcomeCheck.ts(纯决策矩阵):summariseAutofixOutcome 给定
  PR 快照 + 基线 SHA 返回 completed/summary。8 个决策分支单元测试。
- 新增 prFetch.ts(spawn 层):runGhPrView 调 gh CLI,fetchPrHeadSha
  在 launch 时捕获基线 SHA,checkPrAutofixOutcome 组合两者。
- AutofixPrRemoteTaskMetadata 加 initialHeadSha?: string 字段,survive
  --resume。
- launchAutofixPr.ts 模块顶部 registerCompletionChecker('autofix-pr',
  ...),5s throttle 防 gh CLI 调用爆。callAutofixPr 启动时调
  fetchPrHeadSha 传入 metadata。

决策矩阵:
  MERGED                  → done(merged)
  CLOSED 未 merge          → done(closed without fix)
  OPEN 无 baseline        → 继续轮询
  OPEN head 未变           → 继续轮询(agent 还没 push)
  OPEN head 变 + CI pending → 继续轮询
  OPEN head 变 + CI failure → done(surface red,user 决定 retry)
  OPEN head 变 + CI success → done(clean fix)

设计:
- gh CLI 而非 Octokit:复用用户已有 auth,不引入 token 管理
- 决策与 spawn 分文件:prOutcomeCheck 纯函数易测,prFetch 单独 mock
  避免 Bun mock.module 进程级污染(已在 launchAutofixPr.test 注释说明)
- 5s throttle:framework 每 1s 轮询,gh CLI subprocess 太重不能跟上
- 失败兜底:fetchPrHeadSha/checkPrAutofixOutcome 失败均不抛,returns
  null/false,framework 继续走原路径

测试:
- prOutcomeCheck 9 个单测覆盖决策矩阵
- launchAutofixPr 5 个新测试:checker 注册 / fetchPrHeadSha 调用 /
  initialHeadSha 传 metadata / SHA 失败仍能 launch / SHA null 处理

完整方案见 docs/features/remote-agent-completion-analysis.md。

* feat(autofix-pr): 内容回流让本地模型读到 PR 修复结果

Phase 3 of remote-agent completion loop。Phase 2 注册了 completionChecker
让框架能在 PR 合并/关闭/有 push+CI 绿时主动完成 task,但 task-notification
仍然只携带 generic 文本(""${owner}/${repo}#42 merged"")。Phase 3 让本地
模型读到远端 agent 自己产出的结构化结果(commits 列表、files 列表、CI
状态、人类可读 summary)。

实现:
- 新增 extractAutofixResultFromLog (src/commands/autofix-pr/
  extractAutofixResult.ts):从 SDKMessage[] 中扫 <autofix-result> tag,
  优先 hook stdout 后 fallback assistant text,latest-wins。10 个单测。
- RemoteAgentTask 新增 registerContentExtractor 注册式 API + 私有
  enqueueRichRemoteNotification(参考 enqueueRemoteReviewNotification),
  在 3 个 generic 完成路径(archived / completionChecker / result-driven)
  先尝试 tryExtractRichContent,有内容用 rich 变体,没有走 generic。
  isRemoteReview 路径不变(它走自己的 enqueueRemoteReviewNotification)。
- launchAutofixPr.ts 模块顶部 registerContentExtractor('autofix-pr',
  extractAutofixResultFromLog)。initialMessage 加 <autofix-result> 输出
  指令(pr-number / commits-pushed / files-changed / ci-status / summary)。

设计:
- 注册式 API(同 Phase 1 hook + Phase 2 checker):framework 不反向依赖
  命令模块,所有 PR-specific 逻辑在 autofix-pr/
- latest-wins:agent 重试时只取最新 tag,旧 tag 不会污染
- truncated tag → null:开 tag 无对应闭 tag 视为不完整,走 generic
  fallback
- 跨 message 不拼接:开 tag 和闭 tag 在不同 message 视为不完整(避免
  误拼字符串)
- 字符串 content 不解析:assistant.message.content 为 string(非 block
  array)的少见路径直接 skip,不 crash

测试:
- extractAutofixResultFromLog 10 个单测(空 log / 无 tag / hook stdout /
  assistant text / hook_response subtype / 多 tag latest-wins / 截断 /
  hook 后于 assistant 的优先级 / 跨 message 不拼接 / 字符串 content
  graceful)
- launchAutofixPr 3 个新测试(extractor 注册 / initialMessage 含 tag
  schema / extractor 真实行为)

完整方案见 docs/features/remote-agent-completion-analysis.md 第 5.3 节。

* fix(autofix-pr): extractBetween 支持 latest tag 截断时回溯到更早完整对

如果远端 agent 重试时写了完整 <autofix-result> 后又开了一个被截断的
第二个 tag, 旧实现只看 lastIndexOf(open) 然后找不到 close 就返回 null,
导致前面那个完整结果被丢弃。改为从尾向首遍历所有 open tag, 返回第一个
能配对的 open/close 对。

附带:
- docs/features/remote-agent-completion-analysis.md: 9 处裸 fenced block
  补 language tag (text/http), 修复 markdownlint MD040 警告
- 同文件: 两处"三选项" → "三个选项" 符合中文量词习惯

* test(autofix-pr): 补齐 completionChecker / 边界 CI 检查覆盖率

针对 codecov patch coverage gap, 补足三块此前未走到的代码路径:

prOutcomeCheck.ts (原 96.92%, 2 lines missing):
- statusCheckRollup === undefined 路径 (与空数组分支不同, GitHub 在无
  checks 配置的 PR 上直接省略字段)
- COMPLETED 状态但 conclusion 为 null/空 的 in-flight 检查归为 pending

launchAutofixPr.ts (原 58.33%, 15 lines missing):
- registerCompletionChecker arrow body: metadata 缺失早返回 / 节流窗口内
  返回 null / completed=false 返回 null / completed=true 返回 summary /
  initialHeadSha 透传到 checkPrAutofixOutcome
- registerCompletionHook 的 if(meta) 短路两侧: 有 metadata 时清空节流条目,
  无 metadata 时仍释放 active monitor lock

所有新测试沿用现有 mock.module 与 registerXxxMock.mock.calls 拉取注册
回调的模式, 无新增依赖。prOutcomeCheck 11/11 本地通过。

* style: biome check --fix 整形 launchAutofixPr.test 新增段

---------

Co-authored-by: unraid <local@unraid.local>
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-22 21:06:26 +08:00

156 lines
4.2 KiB
TypeScript

// gh CLI integration for autofix-pr: fetches PR snapshots and feeds them
// through the pure decision matrix in prOutcomeCheck.ts. Kept separate so
// tests of the decision matrix never have to mock node:child_process — and
// tests of callAutofixPr can mock this module without polluting the pure
// decision matrix module (Bun mock.module is process-global).
import { spawn } from 'node:child_process'
import {
type AutofixOutcomeProbeResult,
type PrViewPayload,
summariseAutofixOutcome,
} from './prOutcomeCheck.js'
export interface AutofixOutcomeProbeInput {
owner: string
repo: string
prNumber: number
/**
* Head commit SHA captured at /autofix-pr launch. When this differs from
* the current head, autofix has pushed at least one commit.
*/
initialHeadSha?: string
/**
* Timeout for the gh CLI invocation. Caller is the framework's per-tick
* poller, so failures must be bounded — a hung gh process would stall
* the entire poll loop.
*/
timeoutMs?: number
}
const DEFAULT_TIMEOUT_MS = 5_000
/**
* Fetch the PR's current head SHA, state, and CI rollup, and decide whether
* autofix has finished. Returns `{ completed: true, summary }` if so;
* otherwise `{ completed: false }`. Never throws.
*/
export async function checkPrAutofixOutcome(
input: AutofixOutcomeProbeInput,
): Promise<AutofixOutcomeProbeResult> {
const { owner, repo, prNumber, initialHeadSha, timeoutMs } = input
let payload: PrViewPayload
try {
payload = await runGhPrView(
owner,
repo,
prNumber,
timeoutMs ?? DEFAULT_TIMEOUT_MS,
)
} catch {
return { completed: false }
}
return summariseAutofixOutcome(payload, {
owner,
repo,
prNumber,
initialHeadSha,
})
}
/**
* Resolve the PR's current head commit SHA. Used at /autofix-pr launch to
* capture a baseline; later compared against the live SHA to detect pushes.
* Returns null on any failure (network, missing gh, permissions) — the
* caller treats null as "no baseline" and falls back to terminal-state-only
* completion detection.
*/
export async function fetchPrHeadSha(
owner: string,
repo: string,
prNumber: number,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<string | null> {
try {
const payload = await runGhPrView(owner, repo, prNumber, timeoutMs)
return payload.headRefOid || null
} catch {
return null
}
}
interface SpawnError extends Error {
code?: string
}
/**
* Spawn `gh pr view {n} --repo {owner}/{repo} --json ...` and parse the
* result. Rejects on non-zero exit, timeout, or JSON parse failure.
*/
function runGhPrView(
owner: string,
repo: string,
prNumber: number,
timeoutMs: number,
): Promise<PrViewPayload> {
return new Promise((resolve, reject) => {
const proc = spawn(
'gh',
[
'pr',
'view',
String(prNumber),
'--repo',
`${owner}/${repo}`,
'--json',
'headRefOid,state,statusCheckRollup',
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
)
const stdoutChunks: Buffer[] = []
const stderrChunks: Buffer[] = []
let settled = false
const timer = setTimeout(() => {
if (settled) return
settled = true
proc.kill('SIGKILL')
reject(new Error(`gh pr view timed out after ${timeoutMs}ms`))
}, timeoutMs)
proc.stdout.on('data', chunk => stdoutChunks.push(chunk as Buffer))
proc.stderr.on('data', chunk => stderrChunks.push(chunk as Buffer))
proc.on('error', (err: SpawnError) => {
if (settled) return
settled = true
clearTimeout(timer)
reject(err)
})
proc.on('close', code => {
if (settled) return
settled = true
clearTimeout(timer)
if (code !== 0) {
const stderr = Buffer.concat(stderrChunks).toString('utf8').trim()
reject(
new Error(`gh pr view exited ${code}: ${stderr || '<no stderr>'}`),
)
return
}
const stdout = Buffer.concat(stdoutChunks).toString('utf8').trim()
try {
const parsed = JSON.parse(stdout) as PrViewPayload
resolve(parsed)
} catch (e) {
reject(
new Error(`gh pr view JSON parse failed: ${(e as Error).message}`),
)
}
})
})
}