src/runner/entry-lifecycle.ts
Rama main · réplica de lectura del repositorio original.
import { rm } from 'node:fs/promises';
import { join } from 'node:path';
import { hasHumanReplies, readHumanReplies } from './human-feedback.ts';
import { parsePRTask } from './command.ts';
import { prepareCloseStart, preparePendingCloseStart, retireApprovalPlanLifecycle, runClose, retryPendingCloseCompletion, hasPendingClose, resumePendingClose, closeRefusalBody } from './close.ts';
import { deferDelivery, deliverImmediately, enqueueCommentDelivery, requeueOldestBlockedDelivery, type OutboundConnection } from './outbound.ts';
import { needsReviewRecovery, readArtifact } from './review-lifecycle.ts';
import { patchpawPaths } from '../config/paths.ts';
import { readState, writeState, type RunState } from './state.ts';
import { reconcileConflictProposalPublication } from './conflict-proposals.ts';
import { recipientLogins } from '../scm/recipients.ts';
import { approvalPlanDirectory } from './approval-plans.ts';
export type RunnerConfig = { appId?: number; privateKey?: string; snapshotRoot: string; root: string; operatorLogin?: string; appSlug?: string; botLogin?: string };
type CloseCarryForward = Pick<RunState, 'closed_through_comment_id' | 'close_start_notice_id' | 'close_comment_id' | 'close_mentions' | 'completion_notice_id' | 'completion_notice_status' | 'pending_close_refusal'>;
export interface EntryLifecycleInput {
config: RunnerConfig;
resolveConnection: () => Promise<OutboundConnection>;
repo: string;
prNumber: number;
path: string;
appSlug?: string;
botLogin: string;
projectId?: string;
}
export interface EntryLifecycleResult {
appSlug?: string;
botLogin: string;
closeResult?: Awaited<ReturnType<typeof runClose>>;
recoveryTarget?: string;
priorClose?: CloseCarryForward;
priorConflictProposal?: RunState['conflict_proposal'];
}
export async function prepareEntryLifecycle(input: EntryLifecycleInput): Promise<EntryLifecycleResult> {
const { config, repo, prNumber, path } = input;
let { appSlug, botLogin } = input;
let closeResult: Awaited<ReturnType<typeof runClose>> | undefined;
let recoveryTarget: string | undefined;
let priorClose: CloseCarryForward | undefined;
let priorConflictProposal: RunState['conflict_proposal'];
const connection = input.resolveConnection;
const previous = await readState(path);
// Older releases wrote `closed_at` tombstones without retiring generic ApprovalPlan
// sidecars/outbox rows. Converge that residue before loading approval claims or accepting a
// fresh comment. Retirement is idempotent and contains no model, workspace, or SCM write.
if (previous?.phase === 'closed' && previous.closed_at) {
await retireApprovalPlanLifecycle(config.root, repo, prNumber, path);
await rm(approvalPlanDirectory(path), { recursive: true, force: true });
}
if (await hasHumanReplies(path)) await requeueOldestBlockedDelivery(config.root, repo, prNumber);
const handledIds = new Set(previous?.handled_comment_ids ?? []);
const oldest = (await readHumanReplies(path)).find(comment => comment.comment_id > (previous?.closed_through_comment_id ?? 0) && !handledIds.has(comment.comment_id));
if (oldest?.body.includes('/close')) {
const closeSlug = appSlug ?? botLogin.replace(/\[bot\]$/i, '');
if (closeSlug && parsePRTask(oldest.body, closeSlug) === 'close') {
const mentions = recipientLogins([oldest.author, config.operatorLogin]);
const projectId = input.projectId ?? repo;
const prepared = await prepareCloseStart(config, repo, prNumber, path, { comment_id: oldest.comment_id, mentions, bot_login: botLogin, projectId });
try {
const closeConnection = await connection();
closeResult = await runClose(config, repo, prNumber, path, { comment_id: oldest.comment_id, connection: closeConnection,
mentions, bot_login: botLogin, projectId });
} catch (error) {
if (!prepared.start) throw error;
const deferred = await deferDelivery(config.root, prepared.start, error);
closeResult = { status: 'close_start_unpublished', run_ids: prepared.runIds.length, publication: deferred.publication };
}
}
}
if (!closeResult && await hasPendingClose(path)) {
const prepared = await preparePendingCloseStart(config, repo, prNumber, path, botLogin, input.projectId);
try {
closeResult = await resumePendingClose(config, repo, prNumber, path, await connection(), botLogin, input.projectId);
} catch (error) {
if (!prepared?.start) throw error;
const deferred = await deferDelivery(config.root, prepared.start, error);
closeResult = { status: 'close_start_unpublished', run_ids: prepared.runIds.length, publication: deferred.publication };
}
}
if (!closeResult && (previous?.completion_notice_status === 'pending' || previous?.pending_close_refusal)) {
try {
const client = connection;
const bot = botLogin;
if (previous?.completion_notice_status === 'pending') await retryPendingCloseCompletion(path, repo, prNumber, client, config.root, bot);
const refusal = (await readState(path))?.pending_close_refusal;
if (refusal) {
try {
const stored = await enqueueCommentDelivery({ root: config.root, repo, prNumber, purpose: 'close_refusal',
semanticKey: 'close-refusal:' + refusal.comment_id, body: closeRefusalBody,
mentions: recipientLogins([refusal.author]), botLogin: bot,
source: { project_id: input.projectId ?? repo, comment_id: refusal.comment_id } });
const delivered = await deliverImmediately(config.root, stored, { ...await client(), botLogin: bot });
if (delivered.item.status === 'delivered') {
const cleared = await readState(path);
if (cleared) await writeState(path, { ...cleared, pending_close_refusal: undefined });
}
} catch { /* the refusal stays pending for the next entry */ }
}
} catch { /* durable pending markers survive for the next entry */ }
}
if (!closeResult) {
const currentState = await readState(path);
if (currentState?.conflict_proposal) await reconcileConflictProposalPublication(config.root, path, repo, prNumber);
}
if (!closeResult) {
const current = await readState(path);
const { closed_through_comment_id, close_start_notice_id, close_comment_id, close_mentions, completion_notice_id, completion_notice_status, pending_close_refusal } = current ?? {};
if (closed_through_comment_id !== undefined || close_start_notice_id !== undefined || close_comment_id !== undefined
|| close_mentions !== undefined || completion_notice_id !== undefined || completion_notice_status !== undefined || pending_close_refusal !== undefined) {
priorClose = { closed_through_comment_id, close_start_notice_id, close_comment_id, close_mentions,
completion_notice_id, completion_notice_status, pending_close_refusal };
}
priorConflictProposal = current?.conflict_proposal;
if (current && await needsReviewRecovery(current, join(patchpawPaths(config.root).runs, current.run_id))) recoveryTarget = current.run_id;
if (current && await readArtifact(join(patchpawPaths(config.root).runs, current.run_id), 'run-notice.json')
&& (await readArtifact(join(patchpawPaths(config.root).runs, current.run_id), 'notification.json'))?.status !== 'published') recoveryTarget = current.run_id;
}
return { appSlug, botLogin, closeResult, recoveryTarget, priorClose, priorConflictProposal };
}