diff --git a/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/settings/RigSettingsPageClient.tsx b/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/settings/RigSettingsPageClient.tsx index f7e05bd14d..61621384ad 100644 --- a/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/settings/RigSettingsPageClient.tsx +++ b/apps/web/src/app/(app)/gastown/[townId]/rigs/[rigId]/settings/RigSettingsPageClient.tsx @@ -112,6 +112,9 @@ export function RigSettingsPageClient({ townId, rigId, organizationId }: Props) const [autoResolvePrFeedback, setAutoResolvePrFeedback] = useState( undefined ); + const [autoResolveMergeConflicts, setAutoResolveMergeConflicts] = useState( + undefined + ); const [autoMergeDelayMinutes, setAutoMergeDelayMinutes] = useState( undefined ); @@ -136,6 +139,7 @@ export function RigSettingsPageClient({ townId, rigId, organizationId }: Props) setRefineryCodeReview(cfg.code_review); setReviewMode(cfg.review_mode); setAutoResolvePrFeedback(cfg.auto_resolve_pr_feedback); + setAutoResolveMergeConflicts(cfg.auto_resolve_merge_conflicts); setAutoMergeDelayMinutes(cfg.auto_merge_delay_minutes); setMergeStrategy(cfg.merge_strategy); setConvoyMergeMode(cfg.convoy_merge_mode); @@ -183,6 +187,7 @@ export function RigSettingsPageClient({ townId, rigId, organizationId }: Props) code_review: refineryCodeReview, review_mode: reviewMode, auto_resolve_pr_feedback: autoResolvePrFeedback, + auto_resolve_merge_conflicts: autoResolveMergeConflicts, auto_merge_delay_minutes: autoMergeDelayMinutes, merge_strategy: mergeStrategy, convoy_merge_mode: convoyMergeMode, @@ -505,6 +510,46 @@ export function RigSettingsPageClient({ townId, rigId, organizationId }: Props) +
+
+
+ +

+ When a PR has merge conflicts, automatically dispatch an agent to rebase + and resolve them. + {townCfg?.refinery?.auto_resolve_merge_conflicts !== undefined && ( + + (Town default:{' '} + {townCfg.refinery.auto_resolve_merge_conflicts ? 'on' : 'off'}) + + )} +

+
+
+ {autoResolveMergeConflicts !== undefined && ( + + )} + setAutoResolveMergeConflicts(v)} + className={autoResolveMergeConflicts === undefined ? 'opacity-40' : ''} + /> +
+
+
+
+
+
+ +

+ When a PR has merge conflicts, automatically dispatch an agent to rebase and + resolve them. +

+
+ +
+ {autoResolvePrFeedback && (
diff --git a/services/gastown/src/db/tables/town-events.table.ts b/services/gastown/src/db/tables/town-events.table.ts index 436aacecae..72771b8613 100644 --- a/services/gastown/src/db/tables/town-events.table.ts +++ b/services/gastown/src/db/tables/town-events.table.ts @@ -13,6 +13,7 @@ export const TownEventType = z.enum([ 'nudge_timeout', 'pr_feedback_detected', 'pr_auto_merge', + 'pr_conflict_detected', ]); export type TownEventType = z.output; diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index 695c87ea77..b7d0e475d4 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -3673,6 +3673,11 @@ export class TownDO extends DurableObject { pendingEventCount: 0, }; + // Fetch town config once and share across Phase 0 and Phase 1 so that + // applyEvent can use the full fallback chain (rig → town → default) for + // settings like auto_resolve_merge_conflicts. + const townConfig = await this.getTownConfig(); + // Phase 0: Drain events and apply state transitions try { const pending = events.drainEvents(this.sql); @@ -3682,7 +3687,7 @@ export class TownDO extends DurableObject { } for (const event of pending) { try { - reconciler.applyEvent(this.sql, event); + reconciler.applyEvent(this.sql, event, { townConfig }); events.markProcessed(this.sql, event.event_id); } catch (err) { logger.error('reconciler: applyEvent failed', { @@ -3723,7 +3728,6 @@ export class TownDO extends DurableObject { // Phase 1: Reconcile — compute desired state vs actual state const sideEffects: Array<() => Promise> = []; try { - const townConfig = await this.getTownConfig(); const actions = reconciler.reconcile(this.sql, { draining: this._draining, townConfig, diff --git a/services/gastown/src/dos/town/actions.ts b/services/gastown/src/dos/town/actions.ts index bb1482d035..f5b43b861a 100644 --- a/services/gastown/src/dos/town/actions.ts +++ b/services/gastown/src/dos/town/actions.ts @@ -22,6 +22,7 @@ import * as reviewQueue from './review-queue'; import * as patrol from './patrol'; import { getRig } from './rigs'; import { parseGitUrl } from '../../util/platform-pr.util'; +import type { PRStatusResult } from './town-scm'; // ── Bead mutations ────────────────────────────────────────────────── @@ -279,8 +280,8 @@ export type ApplyActionContext = { dispatchAgent: (agentId: string, beadId: string, rigId: string) => Promise; /** Stop an agent's container process. */ stopAgent: (agentId: string) => Promise; - /** Check a PR's status via GitHub/GitLab API. Returns 'open'|'merged'|'closed'|null. */ - checkPRStatus: (prUrl: string) => Promise<'open' | 'merged' | 'closed' | null>; + /** Check a PR's status via GitHub/GitLab API. Returns PRStatusResult or null. */ + checkPRStatus: (prUrl: string) => Promise; /** Check PR for unresolved review comments and failing CI checks. */ checkPRFeedback: (prUrl: string) => Promise; /** Merge a PR via GitHub/GitLab API. */ @@ -724,8 +725,8 @@ export function applyAction(ctx: ApplyActionContext, action: Action): (() => Pro return async () => { try { - const status = await ctx.checkPRStatus(action.pr_url); - if (status !== null) { + const prStatusResult = await ctx.checkPRStatus(action.pr_url); + if (prStatusResult !== null) { // Any non-null result resets the consecutive null counter query( sql, @@ -739,6 +740,7 @@ export function applyAction(ctx: ApplyActionContext, action: Action): (() => Pro `, [action.bead_id] ); + const { status, mergeable_state } = prStatusResult; if (status !== 'open') { ctx.insertEvent('pr_status_changed', { bead_id: action.bead_id, @@ -752,6 +754,124 @@ export function applyAction(ctx: ApplyActionContext, action: Action): (() => Pro const refineryConfig = townConfig.refinery; if (!refineryConfig) return; + if (mergeable_state === 'unknown') { + // GitHub is still computing mergeability — skip this poll and + // check again on the next tick. Do NOT treat 'unknown' as clean + // or dirty to avoid prematurely clearing has_conflicts or + // emitting pr_conflict_detected before GitHub has a definitive answer. + return; + } + + if (mergeable_state === 'dirty') { + // PR has merge conflicts — emit event ONCE per conflict episode. + // The reconciler decides whether to create a conflict bead or an escalation + // based on the rig's auto_resolve_merge_conflicts config. + const conflictMetaRows = z + .object({ has_conflicts: z.unknown() }) + .array() + .parse([ + ...query( + sql, + /* sql */ ` + SELECT json_extract(${beads.columns.metadata}, '$.has_conflicts') AS has_conflicts + FROM ${beads} + WHERE ${beads.bead_id} = ? + `, + [action.bead_id] + ), + ]); + const alreadyMarked = conflictMetaRows[0]?.has_conflicts === 1 || + conflictMetaRows[0]?.has_conflicts === true; + + if (!alreadyMarked) { + // Mark conflict on MR bead metadata + query( + sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.metadata} = json_set( + COALESCE(${beads.columns.metadata}, '{}'), + '$.has_conflicts', 1, + '$.conflicts_detected_at', ? + ), + ${beads.columns.updated_at} = ? + WHERE ${beads.bead_id} = ? + `, + [now(), now(), action.bead_id] + ); + + // Get MR bead source bead ID and branch for the event payload + const mrMetaRows = z + .object({ source_bead_id: z.string().nullable(), branch: z.string().nullable() }) + .array() + .parse([ + ...query( + sql, + /* sql */ ` + SELECT + json_extract(${beads.columns.metadata}, '$.source_bead_id') AS source_bead_id, + ${review_metadata.columns.branch} AS branch + FROM ${beads} + INNER JOIN ${review_metadata} ON ${review_metadata.bead_id} = ${beads.bead_id} + WHERE ${beads.bead_id} = ? + `, + [action.bead_id] + ), + ]); + const sourceBead = mrMetaRows[0]?.source_bead_id ?? null; + const conflictBranch = mrMetaRows[0]?.branch ?? ''; + + ctx.insertEvent('pr_conflict_detected', { + bead_id: action.bead_id, + payload: { + mr_bead_id: action.bead_id, + source_bead_id: sourceBead, + pr_url: action.pr_url, + branch: conflictBranch, + }, + }); + } + + // A dirty PR must not proceed to the auto-merge timer — reset the + // grace-period clock so the timer starts fresh once conflicts are resolved. + query( + sql, + /* sql */ ` + UPDATE ${review_metadata} + SET ${review_metadata.columns.auto_merge_ready_since} = NULL + WHERE ${review_metadata.bead_id} = ? + AND ${review_metadata.columns.auto_merge_ready_since} IS NOT NULL + `, + [action.bead_id] + ); + return; + } else if ( + mergeable_state === 'clean' || + mergeable_state === 'blocked' || + mergeable_state === 'has_hooks' + ) { + // Conflict definitively resolved — clear the has_conflicts flag. + // 'clean': no conflicts, all checks pass. + // 'blocked': no conflicts but checks are failing (e.g. required reviews). + // 'has_hooks': no conflicts but pre-receive hooks are pending. + // 'unknown' is handled above (GitHub still computing — retry next poll). + query( + sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.metadata} = json_remove( + COALESCE(${beads.columns.metadata}, '{}'), + '$.has_conflicts', + '$.conflicts_detected_at' + ), + ${beads.columns.updated_at} = ? + WHERE ${beads.bead_id} = ? + AND json_extract(${beads.columns.metadata}, '$.has_conflicts') IS NOT NULL + `, + [now(), action.bead_id] + ); + } + const wantsAutoResolve = refineryConfig.auto_resolve_pr_feedback === true; const wantsAutoMerge = refineryConfig.auto_merge !== false && @@ -777,10 +897,10 @@ export function applyAction(ctx: ApplyActionContext, action: Action): (() => Pro // If the PR was merged externally during that window, inserting // pr_feedback_detected would create a feedback bead for a merged // PR — leading to a duplicate PR on an already-merged branch. - const freshStatus = await ctx.checkPRStatus(action.pr_url); - if (freshStatus !== 'open') { + const freshStatusResult = await ctx.checkPRStatus(action.pr_url); + if (freshStatusResult?.status !== 'open') { console.log( - `${LOG} poll_pr: PR status changed to '${freshStatus}' during feedback check, skipping feedback for bead=${action.bead_id}` + `${LOG} poll_pr: PR status changed to '${freshStatusResult?.status ?? 'null'}' during feedback check, skipping feedback for bead=${action.bead_id}` ); } else { const existingFeedback = hasExistingFeedbackBead(sql, action.bead_id); diff --git a/services/gastown/src/dos/town/agents.ts b/services/gastown/src/dos/town/agents.ts index 8b6c2765dc..cbcefc468f 100644 --- a/services/gastown/src/dos/town/agents.ts +++ b/services/gastown/src/dos/town/agents.ts @@ -519,6 +519,33 @@ export function prime(sql: SqlStorage, agentId: string): PrimeContext { }; } + // Build PR conflict context if the hooked bead is a PR conflict resolution request, + // or if it is a PR feedback bead that has also accumulated merge conflicts. + let pr_conflict_context: PrimeContext['pr_conflict_context'] = null; + if (hookedBead?.labels.includes('gt:pr-conflict') && hookedBead.metadata) { + const meta = hookedBead.metadata as Record; + pr_conflict_context = { + pr_url: typeof meta.pr_url === 'string' ? meta.pr_url : null, + branch: typeof meta.branch === 'string' ? meta.branch : null, + target_branch: typeof meta.target_branch === 'string' ? meta.target_branch : null, + has_feedback: meta.has_feedback === true || meta.has_feedback === 1, + }; + } else if (hookedBead?.labels.includes('gt:pr-feedback') && hookedBead.metadata) { + // A feedback bead can also have has_conflicts: true when a conflict was detected + // after the feedback bead was already created. Surface the conflict context so the + // agent resolves conflicts first, then addresses review feedback. + const meta = hookedBead.metadata as Record; + if (meta.has_conflicts === true || meta.has_conflicts === 1) { + pr_conflict_context = { + pr_url: typeof meta.pr_url === 'string' ? meta.pr_url : null, + branch: typeof meta.branch === 'string' ? meta.branch : null, + target_branch: + typeof meta.conflict_target_branch === 'string' ? meta.conflict_target_branch : null, + has_feedback: true, + }; + } + } + return { agent, hooked_bead: hookedBead, @@ -526,6 +553,7 @@ export function prime(sql: SqlStorage, agentId: string): PrimeContext { open_beads: openBeads, rework_context, pr_fixup_context, + pr_conflict_context, }; } diff --git a/services/gastown/src/dos/town/config.ts b/services/gastown/src/dos/town/config.ts index 156211115a..403d77fd75 100644 --- a/services/gastown/src/dos/town/config.ts +++ b/services/gastown/src/dos/town/config.ts @@ -89,6 +89,10 @@ export async function updateTownConfig( update.refinery.auto_resolve_pr_feedback ?? current.refinery?.auto_resolve_pr_feedback ?? false, + auto_resolve_merge_conflicts: + update.refinery.auto_resolve_merge_conflicts ?? + current.refinery?.auto_resolve_merge_conflicts ?? + true, auto_merge_delay_minutes: update.refinery.auto_merge_delay_minutes !== undefined ? update.refinery.auto_merge_delay_minutes @@ -191,6 +195,7 @@ export type EffectiveConfig = { review_mode: 'rework' | 'comments'; code_review: boolean; auto_resolve_pr_feedback: boolean; + auto_resolve_merge_conflicts: boolean; auto_merge_delay_minutes: number | null; merge_strategy: MergeStrategy; convoy_merge_mode: 'review-then-land' | 'review-and-merge'; @@ -227,6 +232,10 @@ export function resolveRigConfig( rigOverride?.auto_resolve_pr_feedback ?? townConfig.refinery?.auto_resolve_pr_feedback ?? false, + auto_resolve_merge_conflicts: + rigOverride?.auto_resolve_merge_conflicts ?? + townConfig.refinery?.auto_resolve_merge_conflicts ?? + true, auto_merge_delay_minutes: rigOverride?.auto_merge_delay_minutes !== undefined ? rigOverride.auto_merge_delay_minutes diff --git a/services/gastown/src/dos/town/reconciler.ts b/services/gastown/src/dos/town/reconciler.ts index 91884509e5..32a9ad5d4a 100644 --- a/services/gastown/src/dos/town/reconciler.ts +++ b/services/gastown/src/dos/town/reconciler.ts @@ -211,7 +211,11 @@ type ConvoyRow = z.infer; * * See reconciliation-spec.md §5.2. */ -export function applyEvent(sql: SqlStorage, event: TownEventRecord): void { +export function applyEvent( + sql: SqlStorage, + event: TownEventRecord, + opts?: { townConfig?: TownConfig } +): void { const payload = event.payload; switch (event.event_type) { @@ -403,6 +407,27 @@ export function applyEvent(sql: SqlStorage, event: TownEventRecord): void { const hasFailingChecks = payload.has_failing_checks === true; const hasUncheckedRuns = payload.has_unchecked_runs === true; + // Consolidation: if there's already an open gt:pr-conflict bead for this MR, + // add has_feedback: true to it instead of creating a separate feedback bead. + // The agent resolving conflicts will then also address review feedback afterward. + const existingConflictBeadId = getExistingPrConflictBeadId(sql, mrBeadId); + if (existingConflictBeadId) { + query( + sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.metadata} = json_set(COALESCE(${beads.metadata}, '{}'), '$.has_feedback', 1), + ${beads.columns.updated_at} = ? + WHERE ${beads.bead_id} = ? + `, + [new Date().toISOString(), existingConflictBeadId] + ); + console.log( + `${LOG} pr_feedback_detected: merged into existing conflict bead ${existingConflictBeadId} (mrBeadId=${mrBeadId})` + ); + return; + } + const feedbackBead = beadOps.createBead(sql, { type: 'issue', title: buildFeedbackBeadTitle( @@ -435,6 +460,109 @@ export function applyEvent(sql: SqlStorage, event: TownEventRecord): void { return; } + case 'pr_conflict_detected': { + const mrBeadId = typeof payload.mr_bead_id === 'string' ? payload.mr_bead_id : null; + if (!mrBeadId) { + console.warn(`${LOG} applyEvent: pr_conflict_detected missing mr_bead_id`); + return; + } + + const mrBead = beadOps.getBead(sql, mrBeadId); + if (!mrBead || mrBead.status === 'closed' || mrBead.status === 'failed') return; + + // Idempotent: check for an existing open gt:pr-conflict bead for this pr_url + if (hasExistingPrConflictBead(sql, mrBeadId)) return; + + const prUrl = typeof payload.pr_url === 'string' ? payload.pr_url : ''; + const branch = typeof payload.branch === 'string' ? payload.branch : ''; + const sourceBead = typeof payload.source_bead_id === 'string' ? payload.source_bead_id : null; + + // Read the target_branch from review_metadata + const rmRows = z + .object({ target_branch: z.string() }) + .array() + .parse([ + ...query( + sql, + /* sql */ ` + SELECT ${review_metadata.columns.target_branch} + FROM ${review_metadata} + WHERE ${review_metadata.bead_id} = ? + `, + [mrBeadId] + ), + ]); + const targetBranch = rmRows[0]?.target_branch ?? ''; + + // Read auto_resolve_merge_conflicts using the same fallback chain as + // auto_resolve_pr_feedback: rig override → town config → default (true). + const rig = mrBead.rig_id ? getRig(sql, mrBead.rig_id) : null; + const effectiveConfig = opts?.townConfig + ? resolveRigConfig(opts.townConfig, rig?.config ?? null) + : { auto_resolve_merge_conflicts: rig?.config?.auto_resolve_merge_conflicts !== false }; + const autoResolveConflicts = effectiveConfig.auto_resolve_merge_conflicts !== false; + + if (autoResolveConflicts) { + // Consolidation: if there's already an open gt:pr-feedback bead for this MR, + // add has_conflicts: true to it instead of creating a separate conflict bead. + // The agent handling the feedback bead will resolve conflicts first, then + // address review comments. + const existingFeedbackBeadId = getExistingPrFeedbackBeadId(sql, mrBeadId); + if (existingFeedbackBeadId) { + query( + sql, + /* sql */ ` + UPDATE ${beads} + SET ${beads.columns.metadata} = json_set(COALESCE(${beads.metadata}, '{}'), '$.has_conflicts', 1, '$.conflict_target_branch', ?), + ${beads.columns.updated_at} = ? + WHERE ${beads.bead_id} = ? + `, + [targetBranch, new Date().toISOString(), existingFeedbackBeadId] + ); + console.log( + `${LOG} pr_conflict_detected: merged into existing feedback bead ${existingFeedbackBeadId} (mrBeadId=${mrBeadId})` + ); + return; + } + + const conflictBead = beadOps.createBead(sql, { + type: 'issue', + title: `Resolve merge conflicts on PR: ${branch}`, + body: buildConflictResolutionPrompt(prUrl, branch, targetBranch), + rig_id: mrBead.rig_id ?? undefined, + parent_bead_id: mrBeadId, + labels: ['gt:pr-conflict'], + metadata: { + pr_url: prUrl, + branch, + target_branch: targetBranch, + mr_bead_id: mrBeadId, + source_bead_id: sourceBead, + }, + }); + + // Conflict bead blocks the MR bead (same pattern as feedback beads) + beadOps.insertDependency(sql, mrBeadId, conflictBead.bead_id, 'blocks'); + } else { + // auto_resolve_merge_conflicts disabled — create an escalation bead + beadOps.createBead(sql, { + type: 'escalation', + title: `Merge conflict detected: ${branch}`, + body: `PR ${prUrl} (branch ${branch}) has merge conflicts that require manual resolution.`, + priority: 'high', + metadata: { + pr_url: prUrl, + branch, + target_branch: targetBranch, + mr_bead_id: mrBeadId, + source_bead_id: sourceBead, + conflict: true, + }, + }); + } + return; + } + case 'pr_auto_merge': { const mrBeadId = typeof payload.mr_bead_id === 'string' ? payload.mr_bead_id : null; if (!mrBeadId) { @@ -2163,24 +2291,62 @@ function hasRecentNudge(sql: SqlStorage, agentId: string, tier: string): boolean return rows.length > 0; } +/** Check if an MR bead has a non-terminal conflict bead (gt:pr-conflict) blocking it. */ +function hasExistingPrConflictBead(sql: SqlStorage, mrBeadId: string): boolean { + return getExistingPrConflictBeadId(sql, mrBeadId) !== null; +} + +/** Return the bead_id of a non-terminal conflict bead (gt:pr-conflict) blocking the MR, or null. */ +function getExistingPrConflictBeadId(sql: SqlStorage, mrBeadId: string): string | null { + const rows = z + .object({ bead_id: z.string() }) + .array() + .parse([ + ...query( + sql, + /* sql */ ` + SELECT fb.${beads.columns.bead_id} + FROM ${bead_dependencies} bd + INNER JOIN ${beads} fb ON fb.${beads.columns.bead_id} = bd.${bead_dependencies.columns.depends_on_bead_id} + WHERE bd.${bead_dependencies.columns.bead_id} = ? + AND bd.${bead_dependencies.columns.dependency_type} = 'blocks' + AND fb.${beads.columns.labels} LIKE '%gt:pr-conflict%' + AND fb.${beads.columns.status} NOT IN ('closed', 'failed') + LIMIT 1 + `, + [mrBeadId] + ), + ]); + return rows.length > 0 ? rows[0].bead_id : null; +} + /** Check if an MR bead has a non-terminal feedback bead (gt:pr-feedback) blocking it. */ function hasExistingPrFeedbackBead(sql: SqlStorage, mrBeadId: string): boolean { - const rows = [ - ...query( - sql, - /* sql */ ` - SELECT 1 FROM ${bead_dependencies} bd - INNER JOIN ${beads} fb ON fb.${beads.columns.bead_id} = bd.${bead_dependencies.columns.depends_on_bead_id} - WHERE bd.${bead_dependencies.columns.bead_id} = ? - AND bd.${bead_dependencies.columns.dependency_type} = 'blocks' - AND fb.${beads.columns.labels} LIKE '%gt:pr-feedback%' - AND fb.${beads.columns.status} NOT IN ('closed', 'failed') - LIMIT 1 - `, - [mrBeadId] - ), - ]; - return rows.length > 0; + return getExistingPrFeedbackBeadId(sql, mrBeadId) !== null; +} + +/** Return the bead_id of a non-terminal feedback bead (gt:pr-feedback) blocking the MR, or null. */ +function getExistingPrFeedbackBeadId(sql: SqlStorage, mrBeadId: string): string | null { + const rows = z + .object({ bead_id: z.string() }) + .array() + .parse([ + ...query( + sql, + /* sql */ ` + SELECT fb.${beads.columns.bead_id} + FROM ${bead_dependencies} bd + INNER JOIN ${beads} fb ON fb.${beads.columns.bead_id} = bd.${bead_dependencies.columns.depends_on_bead_id} + WHERE bd.${bead_dependencies.columns.bead_id} = ? + AND bd.${bead_dependencies.columns.dependency_type} = 'blocks' + AND fb.${beads.columns.labels} LIKE '%gt:pr-feedback%' + AND fb.${beads.columns.status} NOT IN ('closed', 'failed') + LIMIT 1 + `, + [mrBeadId] + ), + ]); + return rows.length > 0 ? rows[0].bead_id : null; } /** Build a human-readable title for the feedback bead. */ @@ -2261,6 +2427,47 @@ function buildFeedbackPrompt( return lines.join('\n'); } +/** Build the polecat prompt body for resolving merge conflicts on a PR branch. */ +function buildConflictResolutionPrompt( + prUrl: string, + branch: string, + targetBranch: string +): string { + const lines: string[] = []; + lines.push(`You are resolving merge conflicts on branch \`${branch}\`.`); + lines.push(`The PR is: ${prUrl}`); + lines.push(`The target branch is: \`${targetBranch}\``); + lines.push(''); + lines.push('## Steps'); + lines.push(''); + lines.push('1. Fetch the latest state of the remote:'); + lines.push(' ```'); + lines.push(' git fetch origin'); + lines.push(' ```'); + lines.push(''); + lines.push(`2. Rebase your branch onto the target branch to incorporate its latest changes:`); + lines.push(' ```'); + lines.push(` git rebase origin/${targetBranch}`); + lines.push(' ```'); + lines.push(''); + lines.push('3. If there are conflicts during rebase, resolve them:'); + lines.push(' - Edit the conflicting files to resolve the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`)'); + lines.push(' - Stage the resolved files: `git add `'); + lines.push(' - Continue the rebase: `git rebase --continue`'); + lines.push(' - Repeat until the rebase completes'); + lines.push(''); + lines.push('4. Push the rebased branch:'); + lines.push(' ```'); + lines.push(` git push --force-with-lease origin ${branch}`); + lines.push(' ```'); + lines.push(''); + lines.push('5. Call `gt_done` once the push succeeds, passing both required arguments:'); + lines.push(` - \`pr_url\`: \`${prUrl}\``); + lines.push(` - \`branch\`: \`${branch}\``); + + return lines.join('\n'); +} + // ════════════════════════════════════════════════════════════════════ // Invariant checker — runs after action application to detect // violations of the system invariants from spec §6. diff --git a/services/gastown/src/dos/town/review-queue.ts b/services/gastown/src/dos/town/review-queue.ts index da6402496c..7e618aaa0d 100644 --- a/services/gastown/src/dos/town/review-queue.ts +++ b/services/gastown/src/dos/town/review-queue.ts @@ -532,9 +532,15 @@ export function agentDone(sql: SqlStorage, agentId: string, input: AgentDoneInpu // PR-fixup beads skip the review queue. The polecat pushed fixup commits // to an existing PR branch — no separate review is needed. - if (hookedBead?.labels.includes('gt:pr-fixup')) { + // PR-conflict beads also skip the review queue: the polecat rebased and + // force-pushed the branch to resolve conflicts — closing the bead unblocks + // the parent MR bead so poll_pr can re-check mergeable_state. + if ( + hookedBead?.labels.includes('gt:pr-fixup') || + hookedBead?.labels.includes('gt:pr-conflict') + ) { console.log( - `[review-queue] agentDone: pr-fixup bead ${agent.current_hook_bead_id} — closing directly (skip review)` + `[review-queue] agentDone: ${hookedBead.labels.includes('gt:pr-conflict') ? 'pr-conflict' : 'pr-fixup'} bead ${agent.current_hook_bead_id} — closing directly (skip review)` ); closeBead(sql, agent.current_hook_bead_id, agentId); unhookBead(sql, agentId); diff --git a/services/gastown/src/dos/town/town-scm.ts b/services/gastown/src/dos/town/town-scm.ts index 398e111622..8e2bddf377 100644 --- a/services/gastown/src/dos/town/town-scm.ts +++ b/services/gastown/src/dos/town/town-scm.ts @@ -45,14 +45,20 @@ export async function resolveGitHubToken(ctx: SCMContext): Promise { +): Promise { const townConfig = await ctx.getTownConfig(); // GitHub PR URL format: https://github.com/{owner}/{repo}/pull/{number} @@ -87,9 +93,9 @@ export async function checkPRStatus( const data = GitHubPRStatusSchema.safeParse(json); if (!data.success) return null; - if (data.data.merged) return 'merged'; - if (data.data.state === 'closed') return 'closed'; - return 'open'; + if (data.data.merged) return { status: 'merged' }; + if (data.data.state === 'closed') return { status: 'closed' }; + return { status: 'open', mergeable_state: data.data.mergeable_state }; } // GitLab MR URL format: https://{host}/{path}/-/merge_requests/{iid} @@ -133,9 +139,9 @@ export async function checkPRStatus( const data = GitLabMRStatusSchema.safeParse(glJson); if (!data.success) return null; - if (data.data.state === 'merged') return 'merged'; - if (data.data.state === 'closed') return 'closed'; - return 'open'; + if (data.data.state === 'merged') return { status: 'merged' }; + if (data.data.state === 'closed') return { status: 'closed' }; + return { status: 'open' }; } console.warn(`${TOWN_LOG} checkPRStatus: unrecognized PR URL format: ${prUrl}`); diff --git a/services/gastown/src/prompts/polecat-system.prompt.ts b/services/gastown/src/prompts/polecat-system.prompt.ts index 8815dea0e0..e00e4636b3 100644 --- a/services/gastown/src/prompts/polecat-system.prompt.ts +++ b/services/gastown/src/prompts/polecat-system.prompt.ts @@ -82,6 +82,31 @@ After all gates pass and your work is complete, create a pull request before cal ` : '' } +## PR Conflict Resolution Workflow + +When your hooked bead has the \`gt:pr-conflict\` label, **or** when it has the \`gt:pr-feedback\` label and \`pr_conflict_context\` is present in your context, you are resolving merge conflicts on an existing PR branch. **This is an exception to the "do not switch branches" rule.** You MUST check out the PR branch from your bead metadata (\`pr_conflict_context.branch\`). + +1. Check out the PR branch: \`git fetch origin && git checkout \` +2. Rebase onto the target branch to incorporate its latest changes: + ``` + git rebase origin/ + ``` +3. If there are conflicts during rebase, resolve them: + - Edit conflicting files to resolve conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) + - Stage the resolved files: \`git add \` + - Continue the rebase: \`git rebase --continue\` + - Repeat until the rebase completes +4. Push the rebased branch: + ``` + git push --force-with-lease origin + ``` +5. If the bead metadata has `has_feedback: true`, also address the PR review feedback (see PR Fixup Workflow below) before calling gt_done. + 6. Call \`gt_done\` with both required arguments once all conflicts are resolved (and feedback addressed if applicable): + - \`pr_url\`: the PR URL from \`pr_conflict_context.pr_url\` + - \`branch\`: the branch name from \`pr_conflict_context.branch\` + +Do NOT create a new PR. Push to the existing branch. + ## PR Fixup Workflow When your hooked bead has the \`gt:pr-fixup\` label, you are fixing an existing PR rather than creating new work. **This is the ONE exception to the "do not switch branches" rule.** You MUST check out the PR branch from your bead metadata instead of using the default worktree branch. @@ -101,7 +126,7 @@ Do NOT create a new PR. Push to the existing branch. - Commit after every meaningful unit of work (new function, passing test, config change). - Push after every commit. Do not batch pushes. - Use descriptive commit messages referencing the bead if applicable. -- Branch naming: your branch is pre-configured in your worktree. Do not switch branches — **unless** your bead has the \`gt:pr-fixup\` label (see PR Fixup Workflow above). +- Branch naming: your branch is pre-configured in your worktree. Do not switch branches — **unless** your bead has the \`gt:pr-fixup\` or \`gt:pr-conflict\` label (see workflows above). ## Escalation diff --git a/services/gastown/src/types.ts b/services/gastown/src/types.ts index 7579a4810d..73b7bf604b 100644 --- a/services/gastown/src/types.ts +++ b/services/gastown/src/types.ts @@ -177,6 +177,14 @@ export type PrimeContext = { branch: string | null; target_branch: string | null; } | null; + /** Present when the hooked bead is a PR conflict resolution (gt:pr-conflict label). */ + pr_conflict_context: { + pr_url: string | null; + branch: string | null; + target_branch: string | null; + /** When true, the bead also has pending review feedback to address after resolving conflicts. */ + has_feedback: boolean; + } | null; }; // -- Agent done -- @@ -275,6 +283,9 @@ export const TownConfigSchema = z.object({ /** When enabled, a polecat is automatically dispatched to address * unresolved review comments and failing CI checks on open PRs. */ auto_resolve_pr_feedback: z.boolean().default(false), + /** When enabled, a polecat is automatically dispatched to rebase and + * resolve merge conflicts on open PRs. */ + auto_resolve_merge_conflicts: z.boolean().default(true).optional(), /** After all CI checks pass and all review threads are resolved, * automatically merge the PR after this many minutes. * 0 = immediate, null = disabled (require manual merge). */ @@ -347,6 +358,7 @@ export const RigOverrideConfigSchema = z.object({ /** false = skip refinery entirely */ code_review: z.boolean().optional(), auto_resolve_pr_feedback: z.boolean().optional(), + auto_resolve_merge_conflicts: z.boolean().optional(), auto_merge_delay_minutes: z.number().int().min(0).nullable().optional(), // Merge strategy @@ -412,6 +424,7 @@ export const TownConfigUpdateSchema = z.object({ code_review: z.boolean().optional(), review_mode: z.enum(['rework', 'comments']).optional(), auto_resolve_pr_feedback: z.boolean().optional(), + auto_resolve_merge_conflicts: z.boolean().optional(), auto_merge_delay_minutes: z.number().int().min(0).nullable().optional(), }) .optional(), diff --git a/services/gastown/src/util/platform-pr.util.ts b/services/gastown/src/util/platform-pr.util.ts index c322bae5fa..c05bff3431 100644 --- a/services/gastown/src/util/platform-pr.util.ts +++ b/services/gastown/src/util/platform-pr.util.ts @@ -174,6 +174,8 @@ ${diffSection} export const GitHubPRStatusSchema = z.object({ state: z.string(), merged: z.boolean().optional(), + mergeable: z.boolean().nullable().optional(), + mergeable_state: z.string().optional(), // 'clean', 'dirty', 'blocked', 'unknown', 'unstable' }); /** Schema for GitLab MR status responses (used by checkPRStatus). */