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。
124 lines
3.9 KiB
TypeScript
124 lines
3.9 KiB
TypeScript
// Pure decision matrix for autofix-pr completion detection.
|
|
//
|
|
// Given a snapshot of the PR (state, head SHA, CI rollup) and a baseline
|
|
// head SHA captured at /autofix-pr launch, decide whether autofix has
|
|
// finished. No side effects — extracted from the gh CLI invocation in
|
|
// prFetch.ts so unit tests can exercise every branch without spawning
|
|
// subprocesses.
|
|
|
|
export type AutofixOutcomeProbeResult =
|
|
| { completed: true; summary: string }
|
|
| { completed: false }
|
|
|
|
export interface PrViewPayload {
|
|
headRefOid: string
|
|
state: 'OPEN' | 'CLOSED' | 'MERGED'
|
|
statusCheckRollup?: Array<{
|
|
conclusion?: string | null
|
|
status?: string | null
|
|
name?: string
|
|
}>
|
|
}
|
|
|
|
export interface AutofixOutcomeIdentity {
|
|
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. Optional —
|
|
* absence means we can only finish on terminal PR states (merged/closed).
|
|
*/
|
|
initialHeadSha?: string
|
|
}
|
|
|
|
/**
|
|
* Pure judgement of whether autofix has finished, given a PR snapshot and
|
|
* the baseline head SHA. Decision matrix:
|
|
* - MERGED → done (merged)
|
|
* - CLOSED (not merged) → done (closed without fix)
|
|
* - OPEN, no baseline → keep polling
|
|
* - OPEN, head unchanged → keep polling (agent hasn't pushed)
|
|
* - OPEN, head changed, CI pending → keep polling (wait for CI)
|
|
* - OPEN, head changed, CI failure → done (surface red so user can retry)
|
|
* - OPEN, head changed, CI success → done (clean fix)
|
|
*/
|
|
export function summariseAutofixOutcome(
|
|
payload: PrViewPayload,
|
|
identity: AutofixOutcomeIdentity,
|
|
): AutofixOutcomeProbeResult {
|
|
const { owner, repo, prNumber, initialHeadSha } = identity
|
|
|
|
if (payload.state === 'MERGED') {
|
|
return {
|
|
completed: true,
|
|
summary: `${owner}/${repo}#${prNumber} merged. Autofix monitoring complete.`,
|
|
}
|
|
}
|
|
if (payload.state === 'CLOSED') {
|
|
return {
|
|
completed: true,
|
|
summary: `${owner}/${repo}#${prNumber} closed without merge. Autofix monitoring complete.`,
|
|
}
|
|
}
|
|
|
|
if (!initialHeadSha) return { completed: false }
|
|
if (payload.headRefOid === initialHeadSha) return { completed: false }
|
|
|
|
const ciState = summariseCiRollup(payload.statusCheckRollup)
|
|
if (ciState.state === 'pending') return { completed: false }
|
|
if (ciState.state === 'failure') {
|
|
return {
|
|
completed: true,
|
|
summary: `Autofix pushed commits to ${owner}/${repo}#${prNumber} but CI is failing (${ciState.detail}).`,
|
|
}
|
|
}
|
|
return {
|
|
completed: true,
|
|
summary: `Autofix pushed commits to ${owner}/${repo}#${prNumber}, CI green.`,
|
|
}
|
|
}
|
|
|
|
interface CiSummary {
|
|
state: 'success' | 'pending' | 'failure'
|
|
detail: string
|
|
}
|
|
|
|
function summariseCiRollup(
|
|
rollup: PrViewPayload['statusCheckRollup'],
|
|
): CiSummary {
|
|
if (!rollup || rollup.length === 0) {
|
|
// No checks configured on this repo — treat as success so completion
|
|
// can fire on push alone. PRs without CI are perfectly valid.
|
|
return { state: 'success', detail: 'no checks configured' }
|
|
}
|
|
let pending = 0
|
|
let failed = 0
|
|
const total = rollup.length
|
|
for (const check of rollup) {
|
|
const status = (check.status ?? '').toUpperCase()
|
|
const conclusion = (check.conclusion ?? '').toUpperCase()
|
|
if (status && status !== 'COMPLETED') {
|
|
pending++
|
|
continue
|
|
}
|
|
if (
|
|
conclusion === 'SUCCESS' ||
|
|
conclusion === 'NEUTRAL' ||
|
|
conclusion === 'SKIPPED'
|
|
) {
|
|
continue
|
|
}
|
|
if (conclusion === '') {
|
|
pending++
|
|
continue
|
|
}
|
|
failed++
|
|
}
|
|
if (pending > 0)
|
|
return { state: 'pending', detail: `${pending}/${total} checks pending` }
|
|
if (failed > 0)
|
|
return { state: 'failure', detail: `${failed}/${total} checks failing` }
|
|
return { state: 'success', detail: `${total}/${total} checks passing` }
|
|
}
|