src/runner/run-finalizer.ts
Rama main · réplica de lectura del repositorio original.
import type { ChangeRequestSnapshot, ScmAdapter } from '../scm/types.ts';
import type { WorkspaceState } from '../workspace/manager.ts';
import { disposeWorkspacePath } from '../workspace/repo-store.ts';
import { publishRunNotice } from './publication.ts';
import type { GenericApprovalBinding } from './approval-plan-execution.ts';
import { readArtifact } from './review-lifecycle.ts';
import { readPaused, retainWorkspace } from './resume.ts';
import { stopEvidence } from './stop-evidence.ts';
import type { RunFailure } from './failures.ts';
import { type RunState } from './state.ts';
import { Trace } from '../harness/trace.ts';
import { watchStop } from './stop.ts';
import type { OutboundConnection } from './outbound.ts';
type FinishExtra = { reason?: string; message?: string; [key: string]: unknown };
type Phase = (raw: string) => Promise<void>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isRunFailure(value: unknown): value is RunFailure {
if (!isRecord(value)) return false;
const record = value;
return typeof record.code === 'string' && typeof record.category === 'string'
&& typeof record.retryable === 'boolean' && typeof record.user_action === 'string'
&& typeof record.message === 'string';
}
export interface RunFinalizerInput {
root: string;
repo: string;
prNumber: number;
projectId: string;
runId: string;
executionId: number;
statePath: string;
state: RunState;
trace: Trace;
scm?: ScmAdapter;
resolveConnection?: () => Promise<OutboundConnection>;
botLogin: string;
activeTask: string;
approvedConflictRepair: boolean;
activeWorkspace?: WorkspaceState;
createdWorkspace?: string;
changeRequest?: ChangeRequestSnapshot;
confirmedRemoteHead?: string;
stop?: ReturnType<typeof watchStop>;
workspaceNotice: string;
recipients: () => string[];
genericApprovalClaim?: GenericApprovalBinding;
phase: Phase;
settleGenericApprovalClaim: (phase: 'completed' | 'interrupted') => Promise<void>;
}
export async function finishRun(input: RunFinalizerInput, status: string, extra: FinishExtra = {}, persistedPhase = status): Promise<Record<string, unknown>> {
const { state, trace, activeTask, activeWorkspace, changeRequest, confirmedRemoteHead, stop, genericApprovalClaim } = input;
const stoppedPhase = state.phase;
if (['ci', 'repair'].includes(activeTask) && activeWorkspace && changeRequest && ['needs_human', 'budget_exhausted'].includes(status)) {
await retainWorkspace(input.statePath, trace, { run_id: input.runId, execution_id: input.executionId, task: activeTask as 'ci' | 'repair', workspace: activeWorkspace,
base_sha: changeRequest.diffBaseSha ?? changeRequest.target.sha, base_ref: changeRequest.target.ref, remote_head: confirmedRemoteHead, pause_phase: stoppedPhase,
pause_reason: status === 'needs_human' ? 'human_decision' : 'budget' });
}
if (input.approvedConflictRepair && activeWorkspace && changeRequest
&& ['needs_human', 'budget_exhausted', 'stopped', 'harness_failed', 'provider_unavailable'].includes(status)) {
const pauseStatus = status === 'stopped' ? 'stopped' : status === 'budget_exhausted' ? 'budget_exhausted' : 'needs_human';
const retainedPause = await readPaused(input.statePath);
await retainWorkspace(input.statePath, trace, { run_id: retainedPause?.run_id ?? input.runId, execution_id: retainedPause?.execution_id ?? input.executionId, task: 'conflict', workspace: activeWorkspace,
base_sha: changeRequest.diffBaseSha ?? changeRequest.target.sha, base_ref: changeRequest.target.ref, remote_head: confirmedRemoteHead, pause_phase: stoppedPhase,
pause_reason: pauseStatus === 'stopped' ? 'human_stop' : pauseStatus === 'budget_exhausted' ? 'budget' : 'human_decision', status: pauseStatus });
}
const stopRequest = stop?.request();
if (stopRequest) state.handled_comment_ids = [...new Set([...(state.handled_comment_ids ?? []), stopRequest.comment_id])];
const stopped = !['review_completed', 'conversation_completed', 'custom_completed', 'conflict_completed', 'ci_completed', 'repair_completed', 'publication_pending', 'awaiting_approval'].includes(status);
let evidence = '';
if (stopped) {
try { evidence = await stopEvidence(trace.dir, confirmedRemoteHead); }
catch { evidence = '补充证据读取失败;原始任务结果与 trace 仍保留。'; }
trace.save('stop-evidence.json', { status, failed_phase: stoppedPhase, evidence });
}
state.active = false; await input.phase(persistedPhase);
const result: Record<string, unknown> = { status, run_id: input.runId, execution_id: input.executionId, repo: input.repo, pr_number: input.prNumber, final_head_sha: state.current_head_sha,
...(status === 'budget_exhausted' ? { closeout: 'closeout.json' } : {}),
...(genericApprovalClaim ? { approval_plan: genericApprovalClaim } : {}),
duration_ms: Date.now() - trace.started, ...extra, ...(stopped ? { evidence } : {}) };
trace.save('result.json', result);
trace.emit((status === 'stopped' || status === 'budget_exhausted' || (status === 'needs_human' && stoppedPhase === 'conflict')) ? 'execution_paused' : 'execution_completed', { execution_id: input.executionId, status });
if (stopped) {
const failure = isRunFailure(extra.failure) ? extra.failure : undefined;
const notice = JSON.parse(trace.clean({ run_id: input.runId, head: state.current_head_sha, status, phase: stoppedPhase,
...(failure ? { failure } : {}),
reason: `${status === 'harness_failed' && !failure ? 'Harness 执行/验证过程出错,不等同于代码测试失败。\n' : ''}${status === 'model_output_truncated' && !failure ? '模型连接正常,但单次输出上限已耗尽,未生成完整评审结果;这不是 Provider 不可用,也没有生成可发布的代码验收记录。\n' : ''}${input.workspaceNotice}${extra.reason ?? extra.message ?? '本次任务尚未完成。'}\n${state.last_patchpaw_commit ? `本次记录的 PatchPaw 提交:\`${state.last_patchpaw_commit}\`。\n` : '本次没有记录 PatchPaw 提交或推送;失败不代表代码已交付。\n'}\n${evidence}`,
mentions: input.recipients(), bot_login: input.botLogin }));
trace.save('run-notice.json', notice);
await publishRunNotice({ root: input.root, repo: input.repo, prNumber: input.prNumber, projectId: input.projectId, trace, notice,
botLogin: input.botLogin || undefined, adapter: input.scm, resolveConnection: input.resolveConnection });
}
if (genericApprovalClaim) await input.settleGenericApprovalClaim(status.endsWith('_completed') ? 'completed' : 'interrupted');
return result;
}
export interface DisposeWorkspaceInput {
root: string;
repo: string;
path: string;
trace: Trace;
activeWorkspace?: WorkspaceState;
createdWorkspace?: string;
}
export async function disposeTerminalWorkspace(input: DisposeWorkspaceInput): Promise<void> {
try {
const target = input.activeWorkspace?.path ?? input.createdWorkspace;
if (!target) return;
const result = await readArtifact(input.trace.dir, 'result.json');
if (!result) return;
const paused = await readPaused(input.path);
if (input.activeWorkspace && paused && paused.workspace.path === input.activeWorkspace.path
&& ['budget_exhausted', 'needs_human', 'stopped', 'publication_pending', 'awaiting_approval'].includes(paused.status)) return;
await disposeWorkspacePath(input.root, input.repo, target, input.trace);
input.trace.emit('workspace_disposed', { workspace: target, status: result.status });
} catch (error) {
input.trace.emit('workspace_dispose_failed', { workspace: input.activeWorkspace?.path ?? input.createdWorkspace, message: (error as Error).message });
}
}