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; }; 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> = null; let priorClose: Pick | undefined; let closeResult: Awaited> | undefined; const projectId = scmContext?.projectId ?? repo; let entryConnection: (() => Promise) | undefined; try { entryConnection = scmContext?.connection ?? (async (): Promise => { 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 = 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 | undefined; let feedback: Awaited>['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 = {}) => { 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(); } } } } }