mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
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>
This commit is contained in:
@@ -91,6 +91,14 @@ export type AutofixPrRemoteTaskMetadata = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
prNumber: number;
|
||||
/**
|
||||
* PR head commit SHA captured at /autofix-pr launch. The completionChecker
|
||||
* compares this against the live head to detect when the agent has pushed
|
||||
* new commits. Optional because gh CLI may be unavailable at launch — in
|
||||
* that case the checker falls back to terminal-state-only completion.
|
||||
* Survives --resume via the session sidecar.
|
||||
*/
|
||||
initialHeadSha?: string;
|
||||
};
|
||||
|
||||
export type RemoteTaskMetadata = AutofixPrRemoteTaskMetadata;
|
||||
@@ -114,6 +122,71 @@ export function registerCompletionChecker(remoteTaskType: RemoteTaskType, checke
|
||||
completionCheckers.set(remoteTaskType, checker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after the task transitions to a terminal state and the notification
|
||||
* has been enqueued. Used by command modules to release singleton locks,
|
||||
* clear cached state, or perform other cleanup the framework cannot see.
|
||||
* Hooks must be synchronous and best-effort — errors are logged but never
|
||||
* propagate.
|
||||
*/
|
||||
export type RemoteTaskCompletionHook = (taskId: string, remoteTaskMetadata: RemoteTaskMetadata | undefined) => void;
|
||||
|
||||
const completionHooks = new Map<RemoteTaskType, RemoteTaskCompletionHook>();
|
||||
|
||||
/**
|
||||
* Inspect a completed remote task's accumulated log and return an XML fragment
|
||||
* to inject inline into the completion task-notification. Returning null falls
|
||||
* back to the framework's generic "task completed" notification (file-path
|
||||
* pointer only). Used by command modules whose remote agents emit structured
|
||||
* outcome tags the local model should read directly.
|
||||
*/
|
||||
export type RemoteTaskContentExtractor = (log: SDKMessage[]) => string | null;
|
||||
|
||||
const contentExtractors = new Map<RemoteTaskType, RemoteTaskContentExtractor>();
|
||||
|
||||
/**
|
||||
* Register a content extractor for a remote task type. Called once per
|
||||
* completion in the generic completion branches (archived, completionChecker,
|
||||
* result-driven). isRemoteReview tasks have their own bespoke path and skip
|
||||
* extractors entirely. Errors propagate to the framework which logs and falls
|
||||
* back to generic notification.
|
||||
*/
|
||||
export function registerContentExtractor(remoteTaskType: RemoteTaskType, extractor: RemoteTaskContentExtractor): void {
|
||||
contentExtractors.set(remoteTaskType, extractor);
|
||||
}
|
||||
|
||||
function tryExtractRichContent(task: RemoteAgentTaskState, log: SDKMessage[]): string | null {
|
||||
const extractor = contentExtractors.get(task.remoteTaskType);
|
||||
if (!extractor) return null;
|
||||
try {
|
||||
return extractor(log);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a completion hook for a remote task type. Invoked once after the
|
||||
* task reaches a terminal state in any of the framework's completion branches
|
||||
* (archived session, completionChecker, stableIdle, result). Use this to
|
||||
* release command-module state (e.g. singleton locks) without forcing the
|
||||
* framework to reverse-import from the command package.
|
||||
*/
|
||||
export function registerCompletionHook(remoteTaskType: RemoteTaskType, hook: RemoteTaskCompletionHook): void {
|
||||
completionHooks.set(remoteTaskType, hook);
|
||||
}
|
||||
|
||||
function runCompletionHook(taskId: string, task: RemoteAgentTaskState): void {
|
||||
const hook = completionHooks.get(task.remoteTaskType);
|
||||
if (!hook) return;
|
||||
try {
|
||||
hook(taskId, task.remoteTaskMetadata);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a remote-agent metadata entry to the session sidecar.
|
||||
* Fire-and-forget — persistence failures must not block task registration.
|
||||
@@ -213,6 +286,41 @@ function enqueueRemoteNotification(
|
||||
enqueuePendingNotification({ value: message, mode: 'task-notification' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as enqueueRemoteNotification but inlines a structured XML fragment
|
||||
* (returned by a registered RemoteTaskContentExtractor) so the local model
|
||||
* reads the remote agent's outcome directly instead of having to follow a
|
||||
* file-path pointer. Mode is still 'task-notification' — the framing XML is
|
||||
* the same, only the body differs.
|
||||
*/
|
||||
function enqueueRichRemoteNotification(
|
||||
taskId: string,
|
||||
title: string,
|
||||
status: 'completed' | 'failed' | 'killed',
|
||||
richContent: string,
|
||||
setAppState: SetAppState,
|
||||
toolUseId?: string,
|
||||
): void {
|
||||
if (!markTaskNotified(taskId, setAppState)) return;
|
||||
|
||||
const statusText = status === 'completed' ? 'completed successfully' : status === 'failed' ? 'failed' : 'was stopped';
|
||||
const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}</${TOOL_USE_ID_TAG}>` : '';
|
||||
const outputPath = getTaskOutputPath(taskId);
|
||||
|
||||
const message = `<${TASK_NOTIFICATION_TAG}>
|
||||
<${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>${toolUseIdLine}
|
||||
<${TASK_TYPE_TAG}>remote_agent</${TASK_TYPE_TAG}>
|
||||
<${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}>
|
||||
<${STATUS_TAG}>${status}</${STATUS_TAG}>
|
||||
<${SUMMARY_TAG}>Remote task "${title}" ${statusText}</${SUMMARY_TAG}>
|
||||
</${TASK_NOTIFICATION_TAG}>
|
||||
The remote agent produced the following structured outcome. Summarize the key changes for the user:
|
||||
|
||||
${richContent}`;
|
||||
|
||||
enqueuePendingNotification({ value: message, mode: 'task-notification' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically mark a task as notified. Returns true if this call flipped the
|
||||
* flag (caller should enqueue), false if already notified (caller should skip).
|
||||
@@ -678,9 +786,22 @@ function startRemoteSessionPolling(taskId: string, context: TaskContext): () =>
|
||||
updateTaskState<RemoteAgentTaskState>(taskId, context.setAppState, t =>
|
||||
t.status === 'running' ? { ...t, status: 'completed', endTime: Date.now() } : t,
|
||||
);
|
||||
enqueueRemoteNotification(taskId, task.title, 'completed', context.setAppState, task.toolUseId);
|
||||
const richContent = tryExtractRichContent(task, accumulatedLog);
|
||||
if (richContent) {
|
||||
enqueueRichRemoteNotification(
|
||||
taskId,
|
||||
task.title,
|
||||
'completed',
|
||||
richContent,
|
||||
context.setAppState,
|
||||
task.toolUseId,
|
||||
);
|
||||
} else {
|
||||
enqueueRemoteNotification(taskId, task.title, 'completed', context.setAppState, task.toolUseId);
|
||||
}
|
||||
void evictTaskOutput(taskId);
|
||||
void removeRemoteAgentMetadata(taskId);
|
||||
runCompletionHook(taskId, task);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -691,9 +812,22 @@ function startRemoteSessionPolling(taskId: string, context: TaskContext): () =>
|
||||
updateTaskState<RemoteAgentTaskState>(taskId, context.setAppState, t =>
|
||||
t.status === 'running' ? { ...t, status: 'completed', endTime: Date.now() } : t,
|
||||
);
|
||||
enqueueRemoteNotification(taskId, completionResult, 'completed', context.setAppState, task.toolUseId);
|
||||
const richContent = tryExtractRichContent(task, accumulatedLog);
|
||||
if (richContent) {
|
||||
enqueueRichRemoteNotification(
|
||||
taskId,
|
||||
completionResult,
|
||||
'completed',
|
||||
richContent,
|
||||
context.setAppState,
|
||||
task.toolUseId,
|
||||
);
|
||||
} else {
|
||||
enqueueRemoteNotification(taskId, completionResult, 'completed', context.setAppState, task.toolUseId);
|
||||
}
|
||||
void evictTaskOutput(taskId);
|
||||
void removeRemoteAgentMetadata(taskId);
|
||||
runCompletionHook(taskId, task);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -853,6 +987,7 @@ function startRemoteSessionPolling(taskId: string, context: TaskContext): () =>
|
||||
enqueueRemoteReviewNotification(taskId, reviewContent, context.setAppState);
|
||||
void evictTaskOutput(taskId);
|
||||
void removeRemoteAgentMetadata(taskId);
|
||||
runCompletionHook(taskId, task);
|
||||
return; // Stop polling
|
||||
}
|
||||
|
||||
@@ -870,12 +1005,28 @@ function startRemoteSessionPolling(taskId: string, context: TaskContext): () =>
|
||||
enqueueRemoteReviewFailureNotification(taskId, reason, context.setAppState);
|
||||
void evictTaskOutput(taskId);
|
||||
void removeRemoteAgentMetadata(taskId);
|
||||
runCompletionHook(taskId, task);
|
||||
return; // Stop polling
|
||||
}
|
||||
|
||||
enqueueRemoteNotification(taskId, task.title, finalStatus, context.setAppState, task.toolUseId);
|
||||
// finalStatus is 'completed' | 'failed' on this path — kill is a
|
||||
// separate code path (RemoteAgentTask.kill) and never reaches here.
|
||||
const richContent = tryExtractRichContent(task, accumulatedLog);
|
||||
if (richContent) {
|
||||
enqueueRichRemoteNotification(
|
||||
taskId,
|
||||
task.title,
|
||||
finalStatus,
|
||||
richContent,
|
||||
context.setAppState,
|
||||
task.toolUseId,
|
||||
);
|
||||
} else {
|
||||
enqueueRemoteNotification(taskId, task.title, finalStatus, context.setAppState, task.toolUseId);
|
||||
}
|
||||
void evictTaskOutput(taskId);
|
||||
void removeRemoteAgentMetadata(taskId);
|
||||
runCompletionHook(taskId, task);
|
||||
return; // Stop polling
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user