Ctrl K
ComunidadGuías…

src/runner/pull-request.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { watchStop, TaskStopped } from './stop.ts';
import type { WorkspaceState } from '../workspace/manager.ts';
import type { PRTask, ParsedIntent } from './command.ts';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { createGitHub } from '../github/client.ts';
import { capturePullRequest, type InspectedPR } from '../github/pull-request.ts';
import { resolveTaskDriver, type TaskProgress } from './tasks/driver.ts';
import { decideWriteback, performWriteback } from './writeback.ts';
import { applyRunPhase, assertRunPhase, createRunState } from './phases.ts';
import { workspaceChangesSince } from '../workspace/manager.ts';
import { ensureRepo, fetchPRState, disposeWorkspacePath } from '../workspace/repo-store.ts';
import { git, gitAuth } from '../workspace/git.ts';
import { Trace } from '../harness/trace.ts';
import { providerError } from '../harness/retry.ts';
import { seedContext } from '../harness/context/seed.ts';
import { budget } from '../harness/budget.ts';
import { claimRun, statePath, writeState, readState, workerStatus, type RunState } from './state.ts';
import { readArtifact, reviewCheckpoint } from './review-lifecycle.ts';
import { patchpawPaths } from '../config/paths.ts';
import { recoverLockedRun, reviewConnection } from './recovery.ts';
import { humanFeedback, hasHumanReplies } from './human-feedback.ts';
import { parsePRIntent } from './command.ts';
import { runClose } from './close.ts';
import { HumanHelpRequested } from '../tasks/human-help.ts';
import { type HumanReply } from './human-reply.ts';
import { type ConflictWorkspaceEvidence } from './workspace-evidence.ts';
import { retainWorkspace, readPaused, savePaused } from './resume.ts';
import { enqueueAndDeliverComment, OutboundPending } from './outbound.ts';
import { hasRunnableWork } from './runnable.ts';
import { bootstrapControlPlane, getRepositoryByName, getRepositoryByStorageKey, openControlPlaneDb } from '../control-plane/index.ts';
import type { ControlPlaneDb } from '../control-plane/db.ts';
import type { RuntimeExecution } from '../harness/runtime.ts';
import { classifyRunFailure, terminalStatusForFailure } from './failures.ts';
import {
  markCurrentConflictProposalStale,
  proposalPointerForState, readCurrentConflictProposal,
} from './conflict-proposals.ts';
import {
  readUnfinishedConflictApproval, updateConflictApproval, type ConflictApprovalRecord,
} from './conflict-approval.ts';
import { isSameRepoWriteback } from './command-approval.ts';
import { readCurrentApprovalPlan, readUnfinishedApprovalPlanClaim, type ApprovalPlan, type ApprovalPlanClaim } from './approval-plans.ts';
import { runLegacyConflictRepair } from './legacy-conflict-repair.ts';
import { recoverLegacyConflictApproval } from './legacy-conflict-recovery.ts';
import { prepareExecution, type ExecutionManifest } from './execution-preparation.ts';
import { runTaskDriver } from './task-execution.ts';
import { publishApprovalPlan as publishApprovalPlanDomain } from './approval-plan-publication.ts';
import { runConversationReply } from './conversation-runner.ts';
import { runReviewTask } from './review-runner.ts';
import { runConflictTask } from './conflict-runner.ts';
import { approvalPlanBinding, claimApprovalPlan } from './approval-plan-execution.ts';
import { prepareEntryLifecycle } from './entry-lifecycle.ts';
import { disposeTerminalWorkspace as disposeTerminalWorkspaceDomain, finishRun } from './run-finalizer.ts';
import { refuseCloseOnActiveTask } from './close-refusal.ts';
import { publishTaskWithLifecycle } from './task-publication.ts';
import { settleApprovalPlanClaim } from './approval-plan-settlement.ts';
import { runApprovalPlanTask } from './approval-plan-task.ts';
import { GitHubAdapter } from '../scm/github/adapter.ts';
import type { ChangeRequestSnapshot, ScmAdapter } from '../scm/types.ts';
import { saveScmSnapshot } from '../scm/snapshot.ts';
import { changeRequestRecipients } from '../scm/recipients.ts';
import type { OutboundConnection } from './outbound.ts';

function isApprovalComment(body: string) {
  return /^\s*@[^\s]+\s+\/(?:approval|approve)(?=$|\s)/i.test(body);
}

export type ScmRunContext = {
  adapter: ScmAdapter;
  projectId: string;
  snapshot: ChangeRequestSnapshot;
  snapshotPath?: string;
  connection?: () => Promise<OutboundConnection>;
};

export async function runPullRequest(config: { appId?: number; privateKey?: string; snapshotRoot: string; root: string; operatorLogin?: string; appSlug?: string; botLogin?: string; controlPlaneDb?: ControlPlaneDb; gitlabConnections?: Array<{ id: string; instanceUrl: string; projectIds: string[]; token?: string; botUserId?: string; botLogin?: string }> }, repo: string, number: number, scmContext?: ScmRunContext) {
  if (repo.startsWith('gitlab:') && !scmContext) {
    const { runGitLabMergeRequest } = await import('../scm/gitlab/runner.ts');
    return runGitLabMergeRequest(config, repo, number);
  }
  const path = statePath(patchpawPaths(config.root).state, repo, number);
  if (workerStatus(await readState(path)) === 'running') return { status: 'already_running' };
  if (!await hasRunnableWork(config.root, repo, number)) return { status: 'mention_required' };
  const release = await claimRun(path);
  if (!release) return { status: 'already_running' };
  const github = scmContext ? undefined : (() => {
    if (config.appId === undefined || !config.privateKey) throw new Error('GitHub App configuration is unavailable');
    return createGitHub({ appId: config.appId, privateKey: config.privateKey });
  })();
  let controlPlane: ControlPlaneDb | undefined = config.controlPlaneDb;
  let ownsControlPlane = false;
  let botLogin = scmContext?.adapter.botLogin ?? config.botLogin ?? (config.appSlug ? `${config.appSlug}[bot]` : '');
  let appSlug = config.appSlug ?? config.botLogin?.replace(/\[bot\]$/, '');
  let recoveryTarget: string | undefined;
  let priorConflictProposal: RunState['conflict_proposal'];
  let approvalRecovery: ConflictApprovalRecord | undefined;
  // A durable generic ApprovalPlan claim is an ordered PR-local job just like a legacy Conflict
  // Approval. It must wake the runner without a new human comment and must never consume a later
  // comment while it is being recovered.
  let genericApprovalRecovery: Awaited<ReturnType<typeof readUnfinishedApprovalPlanClaim>> = null;
  let priorClose: Pick<RunState, 'closed_through_comment_id' | 'close_start_notice_id' | 'close_comment_id' | 'close_mentions' | 'completion_notice_id' | 'completion_notice_status' | 'pending_close_refusal'> | undefined;
  let closeResult: Awaited<ReturnType<typeof runClose>> | undefined;
  const projectId = scmContext?.projectId ?? repo;
  let entryConnection: (() => Promise<OutboundConnection>) | undefined;
  try {
    entryConnection = scmContext?.connection ?? (async (): Promise<OutboundConnection> => {
      if (!github) throw new Error('GitHub connection is unavailable');
      const [owner, name] = repo.split('/');
      const { data: installation } = await github.app.rest.apps.getRepoInstallation({ owner, repo: name });
      return { client: github.installation(installation.id), botLogin };
    });
    const entry = await prepareEntryLifecycle({ config, resolveConnection: entryConnection,
      repo, prNumber: number, path, appSlug, botLogin, projectId });
    appSlug = entry.appSlug;
    botLogin = entry.botLogin;
    closeResult = entry.closeResult;
    recoveryTarget = entry.recoveryTarget;
    priorClose = entry.priorClose;
    priorConflictProposal = entry.priorConflictProposal;

  } catch (error) {
    await release(); throw error;
  }
  if (closeResult) {
    try { return closeResult; }
    finally { await release(); }
  }
  if (recoveryTarget) {
    const recoveryConnection = scmContext
      ? reviewConnection(config, repo, number, { adapter: scmContext.adapter, projectId: scmContext.projectId })
      : undefined;
    try { return await recoverLockedRun(config, repo, number, recoveryTarget, recoveryConnection); }
    finally { await release(); }
  }
  try {
    // Load durable approval work only after entry lifecycle has retired compatibility residue
    // from historical /close tombstones. Otherwise an old claim could win over the fresh comment.
    approvalRecovery = await readUnfinishedConflictApproval(path) ?? undefined;
    genericApprovalRecovery = await readUnfinishedApprovalPlanClaim(path);
  } catch (error) {
    await release();
    throw error;
  }
  if (!await hasHumanReplies(path) && !approvalRecovery && !genericApprovalRecovery) {
    // Pure outbound retries belong to the independent scheduler. A direct
    // worker invocation must not allocate a new Agent run for them.
    try { return { status: 'mention_required' }; }
    finally { await release(); }
  }
  const runId = `${new Date().toISOString().replace(/[:.]/g, '-')}-${randomUUID().slice(0, 8)}`;
  const trace = new Trace(join(patchpawPaths(config.root).runs, runId));
  if (config.privateKey) trace.secret(config.privateKey);
  const priorState = await readState(path);
  const priorHandledCommentIds = priorState?.handled_comment_ids ?? [];
  const priorLastPatchpawCommit = priorState?.last_patchpaw_commit ?? null;
  const state: RunState = createRunState({ repo, pr_number: number, run_id: runId, current_head_sha: '', repair_attempts: 0,
    last_patchpaw_commit: null, active: true, pid: process.pid,
    // Carry the retired-comment high-water and close communication facts across generations.
    // `closed_at` belongs only to the tombstone for the generation that /close retired.
    ...(priorClose ?? {}), ...(priorConflictProposal ? { conflict_proposal: priorConflictProposal } : {}) }, 'inspect');
  let inspected: InspectedPR | undefined;
  let snapshotPath: string | undefined = scmContext?.snapshotPath;
  let changeRequest: ChangeRequestSnapshot | undefined = scmContext?.snapshot;
  let scm: ScmAdapter | undefined = scmContext?.adapter;
  let confirmedRemoteHead: string | undefined;
  let executionId = 1;
  let workspaceNotice = '';
  let activeWorkspace: WorkspaceState | undefined;
  let conflictEvidence: { evidence: ConflictWorkspaceEvidence; evidenceSha256: string } | undefined;
  let approvalRequested = false;
  let approvedConflictRepair = false;
  let approvalRecord: ConflictApprovalRecord | undefined = approvalRecovery;
  let genericApprovalClaim: ApprovalPlanClaim | undefined;
  // Assigned inside the run body; the catch path needs it to settle a crashed approved write.
  let settleGenericApprovalClaim: (phase: 'completed' | 'interrupted') => Promise<void> = async () => undefined;
  let runtimeExecution: RuntimeExecution | undefined;
  let manifest: ExecutionManifest;
  // Cleanup ownership of a fresh worktree begins the moment its creation is attempted: a
  // half-created or prepare-failed worktree must never leak outside the run's lifecycle.
  let createdWorkspace: string | undefined;
  let activeTask: PRTask = 'conversation';
  let stop: ReturnType<typeof watchStop> | undefined;
  let feedback: Awaited<ReturnType<typeof humanFeedback>>['context'];
  const requireStop = () => {
    if (!stop) throw new Error('Run stop controller is unavailable');
    return stop;
  };
  const requireControlPlane = () => {
    if (!controlPlane) throw new Error('Control-plane database is unavailable');
    return controlPlane;
  };
  const requireConflictEvidence = () => {
    if (!conflictEvidence) throw new Error('Conflict workspace evidence is unavailable');
    return conflictEvidence;
  };
  const recipients = () => {
    const newCommentIds = new Set(feedback?.new_comment_ids ?? []);
    return changeRequestRecipients(changeRequest, config.operatorLogin,
      (feedback?.comments ?? []).filter(comment => newCommentIds.has(comment.comment_id)).map(comment => comment.author));
  };
  const deliverComment = (purpose: string, semanticKey: string, body: string, mentions: string[], source: Record<string, string | number | null | undefined> = {}) => {
    if (!scm || !botLogin) throw new Error('SCM adapter and bot identity are required before publishing a task report');
    return enqueueAndDeliverComment({ root: config.root, repo, prNumber: number, purpose, semanticKey, body, mentions,
      botLogin, source: { project_id: projectId, ...source } }, { adapter: scm, botLogin });
  };
  const phase = async (raw: string) => { const value = assertRunPhase(raw); applyRunPhase(state, value);
    await writeState(path, state); trace.emit('phase', { phase: value }); console.log(JSON.stringify({ run_id: runId, phase: value })); };
  const finish = (status: string, extra: { reason?: string; message?: string; [key: string]: unknown } = {}, persistedPhase = status) => finishRun({
    root: config.root, repo, prNumber: number, projectId, runId, executionId, statePath: path, state, trace, scm, resolveConnection: entryConnection, botLogin, activeTask, approvedConflictRepair,
    activeWorkspace, changeRequest, confirmedRemoteHead, stop, workspaceNotice, recipients,
    genericApprovalClaim: genericApprovalClaim ? approvalPlanBinding(genericApprovalClaim) : undefined, phase, settleGenericApprovalClaim,
  }, status, extra, persistedPhase);
  const disposeTerminalWorkspace = () => disposeTerminalWorkspaceDomain({
    root: config.root, repo, path, trace, activeWorkspace, createdWorkspace,
  });

  try {
    // A claimed Approval is an ordered PR-local job. Do not consume a later human comment while
    // recovering it; the later comment remains in the inbox for the next generation.
    const recoveryEntry = approvalRecovery || genericApprovalRecovery;
    const replies = recoveryEntry
      ? { handledIds: (await readState(path))?.handled_comment_ids ?? [], context: undefined }
      : await humanFeedback(path, patchpawPaths(config.root).runs, true);
    feedback = replies.context; state.handled_comment_ids = replies.handledIds;
    if (!feedback && !recoveryEntry) return { status: 'mention_required' };
    // Production configuration supplies the app slug. Keeping identity in local config lets
    // failure notices be prepared durably before any GitHub installation/app lookup; identity
    // discovery remains a delivery-time fallback for old callers without the configured slug.
    if (!appSlug) {
      const configured = config.botLogin?.replace(/\[bot\]$/, '');
      if (configured) appSlug = configured;
    }
    if (!appSlug && scm) appSlug = botLogin.replace(/\[bot\]$/i, '');
    if (!appSlug) throw new Error('SCM Bot identity is unavailable');
    botLogin = botLogin || `${appSlug}[bot]`;
    // Do not persist the approval comment's high-water mark during the initial inspect phase.
    // The approval record must win the crash race: once that record is durable, prepare below
    // retires the source comment atomically with the claimed state; before then it remains wakeable.
    const newCommentIds = new Set(feedback?.new_comment_ids ?? []);
    const pendingApprovalComment = feedback?.comments.find(comment => newCommentIds.has(comment.comment_id) && isApprovalComment(comment.body));
    if (pendingApprovalComment) state.handled_comment_ids = priorHandledCommentIds.filter((id: number) => id !== pendingApprovalComment.comment_id);
    const triggeringComment = feedback?.comments.find(comment => newCommentIds.has(comment.comment_id));
    let task: PRTask = 'conversation';
    let intent: ParsedIntent = { kind: 'conversation', repositoryId: '' };
    activeTask = task;
    // An /close arriving mid-task is refused mechanically by the owning worker (no model, no
    // queued destruction). Durable retirement comes FIRST: the comment must never become an
    // executable /close after this task ends, even when the refusal notice cannot be published.
    // A failed notice waits in the tiny state outbox (pending_close_refusal) and the next
    // PatchPaw entry retries it deterministically.
    const refuseClose = (comment: HumanReply) => refuseCloseOnActiveTask({
    root: config.root, repo, prNumber: number, runId, statePath: path, state, trace, projectId, botLogin, connection: async () => {
      if (scm) return { adapter: scm, botLogin };
      if (!github) throw new Error('GitHub connection is unavailable');
      const [owner, name] = repo.split('/');
      const { data: installation } = await github.app.rest.apps.getRepoInstallation({ owner, repo: name });
      return { client: github.installation(installation.id), botLogin };
    }, comment,
    });
    stop = watchStop(path, botLogin, () => state.handled_comment_ids ?? [], refuseClose);
    if (feedback) trace.save('human-feedback.json', feedback);
    await phase('inspect');
    // Approval needs to explain a closed change request without turning the inspection failure
    // into a generic harness error. GitHub keeps its capture artifact; GitLab enters with the
    // already-resolved normalized snapshot and adapter.
    const pr = github ? await capturePullRequest(github, repo, number, config.snapshotRoot, { allowClosed: true }) : undefined;
    if (pr) {
      inspected = pr;
      snapshotPath = pr.snapshotPath;
    }
    if (!scm) {
      if (!pr) throw new Error('SCM inspection did not produce a GitHub capture');
      scm = new GitHubAdapter(pr.client, botLogin);
    }
    changeRequest = await scm.readChangeRequest(projectId, number, { allowClosed: true });
    snapshotPath = (await saveScmSnapshot(config.snapshotRoot, changeRequest, `run-${runId}`)).path;
    state.current_head_sha = changeRequest.source.sha;
    confirmedRemoteHead = changeRequest.source.sha;
    const adapter = scm;
    const currentChangeRequest = changeRequest;
    // Bootstrap only fills genuinely missing control-plane records. Existing repository
    // edits remain authoritative, so every new invocation resolves the current config.
    controlPlane = config.controlPlaneDb ?? await openControlPlaneDb(config.root);
    ownsControlPlane = !config.controlPlaneDb;
    await bootstrapControlPlane({ root: config.root, repositories: scmContext ? [{ fullName: changeRequest.repository.pathWithNamespace,
      displayName: changeRequest.repository.pathWithNamespace, scmKind: changeRequest.kind, connectionId: changeRequest.repository.connectionId,
      remoteProjectId: changeRequest.repository.remoteProjectId, pathWithNamespace: changeRequest.repository.pathWithNamespace,
      webUrl: changeRequest.repository.webUrl, cloneUrl: changeRequest.repository.cloneUrl, storageKey: repo }] : [{ fullName: repo }], controlPlaneDb: controlPlane });
    const repository = scmContext ? await getRepositoryByStorageKey(controlPlane, repo) : await getRepositoryByName(controlPlane, repo);
    if (!repository) throw new Error(`Control-plane repository is missing after bootstrap: ${repo}`);
    const preparedControlPlane = requireControlPlane();
    intent = triggeringComment
      ? await parsePRIntent(controlPlane, repository.id, triggeringComment.body, appSlug)
      : { kind: 'control', control: 'approval' };
    let currentApprovalPlan = await readCurrentApprovalPlan(path);
    const requireApprovalPlan = () => {
      if (!currentApprovalPlan) throw new Error('Approval Plan is unavailable');
      return currentApprovalPlan;
    };
    task = intent.kind === 'command' ? intent.executionType : intent.kind === 'control' && intent.control === 'approval' && currentApprovalPlan
      ? currentApprovalPlan.plan.execution_type : intent.kind === 'control' && intent.control === 'stop' ? 'stop'
      : intent.kind === 'control' && intent.control === 'close' ? 'close'
      : intent.kind === 'control' && intent.control === 'approval' ? 'conflict' : 'conversation';
    activeTask = task;
    approvalRequested = intent.kind === 'control' && intent.control === 'approval';
    if (!['open', 'opened'].includes(changeRequest.state) && !approvalRequested) throw new Error('Change request is not open or has no source repository');
    if (intent.kind === 'control' && intent.control === 'close') {
      return await finish('needs_human', { reason: '/close 是机械生命周期命令,但本条评论未能在前置生命周期入口处理;没有启动模型或修复。请重发一次单独的 /close。' });
    }
    const token = async () => {
      const installationGitToken = scm?.installationGitToken;
      if (!installationGitToken) throw new Error('SCM installation Git token is unavailable');
      const value = await installationGitToken.call(scm);
      trace.secret(value); trace.secret(Buffer.from(`x-access-token:${value}`).toString('base64'));
      return value;
    };
    await requireStop().guard();
    const retained = await readPaused(path);
    const currentConflictProposal = await readCurrentConflictProposal(path);
    currentApprovalPlan = currentApprovalPlan ?? await readCurrentApprovalPlan(path);
    const retainedForDiscussion = retained && ['budget_exhausted', 'needs_human', 'stopped', 'publication_pending', 'awaiting_approval', 'claimed', 'repairing'].includes(retained.status);
    const discussingConflict = task === 'conversation' && !!retainedForDiscussion && !!currentConflictProposal
      && retained?.workspace.path === currentConflictProposal.pointer.workspace_path
      && ['draft', 'publication_pending', 'published'].includes(currentConflictProposal.proposal.status);
    const discussingPause = (task === 'conversation' && !!retainedForDiscussion && !currentConflictProposal)
      || discussingConflict;
    if (task === 'stop' && currentConflictProposal) {
      // /stop is mechanical even while a proposal is awaiting publication/approval. Preserve the
      // proposal pointer and retained checkout, but never turn a stop comment into a model turn.
      state.conflict_proposal = proposalPointerForState(currentConflictProposal.proposal, currentConflictProposal.pointer.workspace_path);
      return await finish('stopped', { reason: 'Conflict Proposal 已保留;/stop 只停止当前本地会话,不会调用模型或启动修复。' });
    }
    if (task === 'stop' && !discussingPause) return await finish('stopped', { reason: '当前没有正在执行的任务或可继续的暂停工作区。没有启动新任务。' });
    if (retained && ['budget_exhausted', 'needs_human', 'stopped'].includes(retained.status)
      && !discussingPause && task !== retained.task) {
      return await finish('needs_human', { reason: `当前有暂停的 /${retained.task} 工作区。请先沟通并用 /${retained.task} 继续该任务;此次没有另建工作区。` });
    }
    await phase('workspace');
    // Every run refreshes the one shared object store for this GitHub repo BEFORE the
    // resume/fresh decision; fetching never mutates an existing paused worktree's HEAD/index.
    await ensureRepo(config.root, repo, changeRequest.repository.cloneUrl, trace);
    const fetchPlan = scm.workspaceFetchPlan(changeRequest);
    const fetchedBase = await fetchPRState(config.root, repo, { headSha: changeRequest.source.sha, baseRef: changeRequest.target.ref,
      headFetch: fetchPlan.head }, trace, gitAuth(await token(), changeRequest.repository.cloneUrl, 'x-access-token', scm.connection.instanceUrl));
    const currentBase = { ref: fetchedBase.baseRef, sha: fetchedBase.currentBaseTipSha };
    const legacyRecovery = await recoverLegacyConflictApproval({
      root: config.root, statePath: path, repo, prNumber: number, runId, executionId, botLogin, trace, state, changeRequest, scm, projectId, currentBase,
      retained: retained ?? undefined, currentConflictProposal: currentConflictProposal ?? undefined, approvalRequested, approvalRecovery,
      controlPlane, recipients, finish, confirmedRemoteHead,
      setApprovalRecord: value => { approvalRecord = value; },
      setConfirmedRemoteHead: value => { confirmedRemoteHead = value; },
    });
    approvalRecord = legacyRecovery.approvalRecord;
    confirmedRemoteHead = legacyRecovery.confirmedRemoteHead;
    if (legacyRecovery.handled) return legacyRecovery.result;

    if (discussingConflict && currentConflictProposal) {
      const basis = currentConflictProposal.proposal.basis;
      const staleReason = basis.pr_head_sha !== changeRequest.source.sha ? 'pr_head_changed'
        : basis.current_base_tip_sha !== currentBase.sha ? 'current_base_tip_changed'
        : basis.base_ref !== currentBase.ref ? 'base_ref_changed' : undefined;
      if (staleReason) {
        if (!retained) throw new Error('Conflict discussion is missing its retained workspace');
        await markCurrentConflictProposalStale(path, retained.workspace.path, staleReason);
        await savePaused(path, { ...retained, status: 'stale', reason: staleReason });
        state.conflict_proposal = { ...proposalPointerForState(currentConflictProposal.proposal, retained.workspace.path), status: 'stale' };
        await writeState(path, applyRunPhase({ ...state, active: false }, 'stale'));
        try { await disposeWorkspacePath(config.root, repo, retained.workspace.path, trace); }
        catch (error) { trace.emit('stale_workspace_dispose_failed', { workspace: retained.workspace.path, reason: staleReason, message: (error as Error).message }); }
        return await finish('stale', { reason: `当前 Git 事实已变化(${staleReason}),旧 Conflict Proposal 已标记 stale;不会基于旧工作区继续讨论或发布修订。请重新发送 /conflict。` });
      }
    }
    const preparedExecution = await prepareExecution({
      root: config.root, repo, prNumber: number, runId, trace, state, changeRequest: currentChangeRequest, scm: adapter, projectId,
      snapshotPath, path, task, intent, repository, currentApprovalPlan,
      approvalRequested, approvedConflictRepair, approvalRecord, approvalRecovery, triggeringComment, feedback,
      currentBase, retained, currentConflictProposal, discussingPause, discussingConflict, botLogin, stop: requireStop(), workspaceNotice, executionId, phase, finish,
      controlPlane: preparedControlPlane,
      setCreatedWorkspace: value => { createdWorkspace = value; },
      setActiveWorkspace: value => { activeWorkspace = value; },
      setExecutionId: value => { executionId = value; },
    });
    if (preparedExecution.handled) return preparedExecution.result;
    approvedConflictRepair = preparedExecution.approvedConflictRepair;
    approvalRecord = preparedExecution.approvalRecord;
    executionId = preparedExecution.executionId;
    runtimeExecution = preparedExecution.runtimeExecution;
    const preparedRuntimeExecution = runtimeExecution;
    if (!preparedRuntimeExecution) throw new Error('Runtime execution is unavailable after preparation');
    manifest = preparedExecution.manifest;
    activeWorkspace = preparedExecution.ws;
    workspaceNotice = preparedExecution.workspaceNotice;
    const { genericApprovalWrite, resumed, ws, snapshot, snapshotReference, conflictEvidence: preparedConflictEvidence } = preparedExecution;
    conflictEvidence = preparedConflictEvidence;

    const permissionPhase = genericApprovalWrite ? 'approved_write' as const : snapshot.command?.permission === 'read_write_approval' ? 'planning' as const : 'normal' as const;
    const taskOptions = { task, prompt: '', ws, trace, runId, currentBase, execution: preparedRuntimeExecution, permissionPhase,
      approvalMessage: genericApprovalWrite ? '用户批准了你的计划' : undefined,
      preventGitPush: snapshot.command?.permission !== 'read_only',
      prMemory: { root: patchpawPaths(config.root).memory, repo, number },
      repairStartHead: resumed ? (resumed.remote_head ?? ws.initialHead) : undefined,
      stopSignal: stop.signal, stopRequest: stop.request,
      onCloseout: () => phase('repair_closeout') };
    // Harness-owned writeback applies to every execution type that has write capability.
    // read_write_approval only becomes writable in the approved_write phase.
    // Whether this execution may write back is the writeback domain's decision, not a boolean
    // assembled here. It runs before the Agent receives write tools, so a fork is refused before
    // any capability exists rather than after something has already been changed.
    const writeback = decideWriteback({ permission: snapshot.command?.permission, approvedWrite: genericApprovalWrite,
      sameRepo: isSameRepoWriteback(currentChangeRequest, repo) });
    if (writeback.kind === 'refused') {
      return await finish('needs_human', { reason: writeback.reason, approval_rejection_code: writeback.code });
    }
    const seed = async () => ({ ...await seedContext(currentChangeRequest, ws, trace, currentBase), human_feedback: feedback,
      execution_id: executionId, resumed: !!resumed, retained_for_discussion: !!discussingPause, workspace_notice: workspaceNotice, previous_stop_report: resumed ? await readArtifact(trace.dir, 'resume-stop-report.json') : null,
      previous_closeout: resumed ? await readArtifact(trace.dir, 'resume-closeout.json') : null,
      conflict_workspace_evidence: conflictEvidence?.evidence ?? null,
      pending_conflict_proposal: discussingConflict || approvedConflictRepair ? currentConflictProposal?.proposal ?? null : null,
      conflict_approval: approvedConflictRepair ? approvalRecord ?? null : null,
      approved_conflict_proposal: approvedConflictRepair ? currentConflictProposal?.proposal ?? null : null });
    const beginTask = async (task: string) => { manifest.task_chain.push(task); trace.save('manifest.json', manifest);
      await requireStop().guard();
      await phase(task === 'review' ? 'review_running' : task); };
    const assertCurrent = async () => {
      const current = await adapter.readChangeRequest(projectId, number, { allowClosed: true });
      if (!['open', 'opened'].includes(current.state) || current.source.sha !== state.current_head_sha
          || current.source.ref !== currentChangeRequest.source.ref
          || current.source.pathWithNamespace.toLowerCase() !== currentChangeRequest.source.pathWithNamespace.toLowerCase()
          || current.target.ref !== currentChangeRequest.target.ref || current.target.sha !== ws.mainSha) {
        throw new Error('Change request head, target, source, or state changed during the run');
      }
    };
    // The durable generic approval claim only exists while an approved write is in flight. It is
    // settled once the write has a durable outcome, so a crash-recovered worker never replays it.
    settleGenericApprovalClaim = phase => settleApprovalPlanClaim({ path, trace, claim: genericApprovalClaim, phase });
    const deliver = (status: 'custom_completed' | 'conflict_completed' | 'ci_completed' | 'repair_completed', body: string) => publishTaskWithLifecycle({
      root: config.root, repo, prNumber: number, runId, path, workspacePath: ws.path, status, body, workspaceNotice,
      headSha: state.current_head_sha, projectId, mentions: recipients(), botLogin, adapter, trace,
      approvalPlan: genericApprovalClaim ? approvalPlanBinding(genericApprovalClaim) : undefined, guard: () => requireStop().guard(), finish,
    });
    const publishApprovalPlan = async (body: string) => publishApprovalPlanDomain({
      root: config.root, repo, prNumber: number, runId, executionId, path, task: task as ApprovalPlan['execution_type'], body,
      snapshot, snapshotSha256: snapshotReference.snapshot_sha256, currentApprovalPlan,
      setCurrentApprovalPlan: value => { currentApprovalPlan = value; },
      conflictEvidence: requireConflictEvidence(), currentBase, source: currentChangeRequest, adapter, projectId, workspace: ws, state, trace,
      mentions: recipients(), botLogin, finish,
    });
    if (genericApprovalWrite) {
      const execution = await claimApprovalPlan({
        root: config.root, repo, prNumber: number, runId, executionId, path, trace, state, source: currentChangeRequest, adapter, projectId, currentBase, workspace: ws,
        snapshot, snapshotSha256: snapshotReference.snapshot_sha256, currentApprovalPlan: requireApprovalPlan(),
        recovery: genericApprovalRecovery, claim: genericApprovalClaim, setClaim: value => { genericApprovalClaim = value; },
        triggeringComment, botLogin, priorLastPatchpawCommit, finish,
      });
      genericApprovalClaim = execution.claim;
      if (execution.handled) return execution.result;
    }
    // A Conflict run whose merge is already satisfied is a mechanical no-op under every
    // Permission, including the read-only planning phase of read_write_approval.
    if (task === 'conflict' && !approvedConflictRepair && !ws.mergePending
        && (!resumed || (resumed.local_head === currentChangeRequest.source.sha && !(await git(ws.path, ['status', '--porcelain'])).stdout.trim()))) {
      return await deliver('conflict_completed', '当前 PR 已包含目标分支,无需修复合并冲突;没有修改或提交代码。');
    }
    const writebackPermit = writeback.kind === 'allowed' ? writeback.permit : undefined;
    const writebackEnabled = writebackPermit !== undefined;
    // Harness-owned writeback. The sequence lives in the writeback domain; this closure only
    // records its outcome in run state, which is the runner's business.
    const publish = async (kind: string) => {
      if (!writebackPermit) throw new Error('Writeback was requested without an enabled permission capability');
      const outcome = await performWriteback({ workspace: ws, trace, scm: adapter, projectId,
        changeRequestNumber: number, expectedBaseSha: currentBase.sha, sameRepo: isSameRepoWriteback(currentChangeRequest, repo), kind,
        permit: writebackPermit, previousHead: state.current_head_sha, baseRef: currentChangeRequest.target.ref, headRef: currentChangeRequest.source.ref,
        headRepo: currentChangeRequest.source.pathWithNamespace, remoteUrl: currentChangeRequest.repository.cloneUrl,
        credentialScopeUrl: adapter.connection.instanceUrl, token, guard: () => requireStop().guard(), beforePush: async (candidateSha) => {
          state.current_head_sha = candidateSha;
          state.last_patchpaw_commit = candidateSha;
          await phase('publishing');
        } });
      state.current_head_sha = outcome.confirmedRemoteHead; state.last_patchpaw_commit = outcome.commitSha;
      confirmedRemoteHead = outcome.confirmedRemoteHead;
    };
    const hasWorkspaceChanges = async () => (await workspaceChangesSince(ws, state.current_head_sha, trace)).has_changes;
    // A task whose execution this runner cannot express generically runs through its driver. The
    // driver decides what the task needs from freshly-read real state; this runner performs every
    // durable effect it asks for — the Agent turn, writeback, publication, the terminal result.
    // The runner therefore never branches on which task this is: the loop below is identical for
    // any driver, and the only task-shaped facts it handles are the ones the driver declares.
    const taskDriver = resolveTaskDriver(snapshot.template_type);
    if (taskDriver) {
      const progress: TaskProgress = { turns: [] };
      const execution = await runTaskDriver({
        driver: taskDriver, phase: permissionPhase, writable: writebackEnabled, repo, scm: adapter,
        projectId, changeRequestNumber: number, trace,
        signal: stop.signal, options: taskOptions, repairAttempts: () => state.repair_attempts,
        repairBudget: budget.repairAttempts, resumed: resumed ? { pausePhase: resumed.pause_phase } : null, progress,
        guardCycle: async (phaseLabel: string) => { await phase(phaseLabel); await assertCurrent(); await requireStop().guard(); },
        beginTask, seed, finish, deliver, hasWorkspaceChanges, publish,
        headSha: () => state.current_head_sha,
        saveRepairAttempts: async () => { state.repair_attempts++; await writeState(path, state); },
        publishApprovalPlan,
      });
      if (execution.handled) return execution.result;
    }
    if (!approvalRequested && snapshot.command?.permission === 'read_write_approval') {
      const planning = await runApprovalPlanTask({ task, taskOptions, seed, beginTask,
        publishApprovalPlan, finish });
      if (planning.handled) return planning.result;
      throw new Error(`No planning path for execution type ${snapshot.template_type}; it needs a task driver.`);
    }
    if (task === 'conversation' || task === 'stop') {
      return await runConversationReply({
        repo, prNumber: number, runId, trace, scm: adapter, projectId,
        taskOptions, seed, beginTask, deliverComment, recipients, finish,
      });
    }
    if (task === 'review') {
      return await runReviewTask({
        root: config.root, repo, prNumber: number, trace, state, path, resumed: resumed ? { run_id: resumed.run_id } : null,
        currentHead: () => state.current_head_sha, taskOptions, seed, workspaceNotice,
        writable: writebackEnabled, hasWorkspaceChanges, publish, stopGuard: () => requireStop().guard(),
        savePausedCompleted: async () => { const paused = await readPaused(path); if (paused?.workspace.path === ws.path) await savePaused(path, { ...paused, status: 'completed' }); },
        beginTask, connection: async () => ({ adapter, projectId, botLogin, mentions: recipients() }), mentions: recipients, finish, settleApproval: settleGenericApprovalClaim,
      });
    }
    if (task === 'conflict') {
      if (approvedConflictRepair) {
        if (!approvalRecord || !currentConflictProposal) throw new Error('Approved Conflict repair is missing its durable approval or proposal.');
        await beginTask('conflict-repair');
        const outcome = await runLegacyConflictRepair({
        root: config.root, repo, prNumber: number, runId, executionId, path, trace, changeRequest: currentChangeRequest, scm: adapter, projectId, currentBase,
          retained: retained ?? (() => { throw new Error('Approved Conflict repair is missing its retained workspace.'); })(), workspace: ws, state, approvalRecord, proposal: currentConflictProposal.proposal, confirmedRemoteHead,
          taskOptions, seed, phase, finish, token, guard: () => requireStop().guard(), botLogin, recipients,
          setApprovalRecord: value => { approvalRecord = value; },
          setConfirmedRemoteHead: sha => { confirmedRemoteHead = sha; },
        });
        approvalRecord = outcome.approvalRecord;
        confirmedRemoteHead = outcome.confirmedRemoteHead;
        return outcome.result;
      }
      if (!ws.mergePending && (!resumed || (resumed.local_head === currentChangeRequest.source.sha && !(await git(ws.path, ['status', '--porcelain'])).stdout.trim()))) return await deliver('conflict_completed', '当前 change request 已包含目标分支,无需修复合并冲突;没有修改或提交代码。');
      return await runConflictTask({
        repo, prNumber: number, scm: adapter, projectId,
        baseSha: currentChangeRequest.diffBaseSha ?? currentChangeRequest.target.sha, baseRef: currentChangeRequest.target.ref, taskOptions, seed, trace, workspace: ws, path, runId, beginTask,
        retainWorkspace: async input => retainWorkspace(path, trace, input),
        executionId, writebackEnabled, hasWorkspaceChanges, publish, deliver, finish,
        currentHead: () => state.current_head_sha,
      });
    }
  } catch (error) {
    if (error instanceof TaskStopped || stop?.signal.aborted) {
      if (approvalRecord?.status === 'accepted' && approvalRecord.phase !== 'completed') {
        approvalRecord = await updateConflictApproval(path, approvalRecord.approval_id, { phase: 'interrupted' }) ?? approvalRecord;
        trace.emit('conflict_approval_interrupted', { approval_id: approvalRecord.approval_id, reason: 'human_stop' });
      }
      await settleGenericApprovalClaim('interrupted');
      if (activeWorkspace && changeRequest && ['conflict', 'ci', 'repair', 'review'].includes(activeTask)) {
        await retainWorkspace(path, trace, { run_id: runId, execution_id: executionId, task: activeTask as 'conflict' | 'ci' | 'repair' | 'review',
          base_sha: changeRequest.diffBaseSha ?? changeRequest.target.sha, base_ref: changeRequest.target.ref, workspace: activeWorkspace, pause_reason: 'human_stop',
          remote_head: confirmedRemoteHead, pause_phase: state.phase });
      }
      return await finish('stopped', { reason: error instanceof TaskStopped ? error.message : new TaskStopped().message });
    }
    if (approvalRecord?.status === 'accepted' && approvalRecord.phase === 'repairing') {
      approvalRecord = await updateConflictApproval(path, approvalRecord.approval_id, { phase: 'interrupted' }) ?? approvalRecord;
      trace.emit('conflict_approval_interrupted', { approval_id: approvalRecord.approval_id, reason: 'harness_or_provider_failure' });
    }
    if (error instanceof HumanHelpRequested) {
      await settleGenericApprovalClaim('interrupted');
      return await finish('needs_human', { reason: error.message });
    }
    trace.emit('run_error', { phase: state.phase, ...providerError(error), message: (error as Error).message });
    if (await readArtifact(trace.dir, 'review.json')) {
      if (error instanceof OutboundPending) trace.save('result.json', { status: 'review_publication_pending', run_id: runId,
        repo, pr_number: number, final_head_sha: state.current_head_sha, delivery_id: error.delivery.delivery_id });
      state.active = false; await phase(await reviewCheckpoint(trace.dir));
      return { status: 'publication_interrupted', run_id: runId };
    }
    await settleGenericApprovalClaim('interrupted');
    const failure = classifyRunFailure(error);
    return await finish(terminalStatusForFailure(failure), { failed_phase: state.phase,
      error: providerError(error), message: failure.message, failure });
  } finally {
    try { await stop?.close(); }
    finally { try { await disposeTerminalWorkspace(); } finally { try { if (ownsControlPlane) controlPlane?.close(); } finally { await release(); } } }
  }
}