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/actions.ts b/services/gastown/src/dos/town/actions.ts index bb1482d035..13fcfa9535 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,108 @@ export function applyAction(ctx: ApplyActionContext, action: Action): (() => Pro const refineryConfig = townConfig.refinery; if (!refineryConfig) 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 === 'unknown') { + // Conflict resolved — clear the has_conflicts flag + 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 +881,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/reconciler.ts b/services/gastown/src/dos/town/reconciler.ts index 91884509e5..a67bfde950 100644 --- a/services/gastown/src/dos/town/reconciler.ts +++ b/services/gastown/src/dos/town/reconciler.ts @@ -435,6 +435,91 @@ 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 from rig config (default true if not configured yet). + // townConfig is not available in applyEvent, so we read from rig config directly. + // The field may not exist yet (added by a parallel bead) — default to true. + const rig = mrBead.rig_id ? getRig(sql, mrBead.rig_id) : null; + const autoResolveConflictsField = z + .object({ auto_resolve_merge_conflicts: z.boolean().optional() }) + .safeParse(rig?.config); + const autoResolveConflicts = + autoResolveConflictsField.success + ? autoResolveConflictsField.data.auto_resolve_merge_conflicts !== false + : true; + + if (autoResolveConflicts) { + 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,6 +2248,26 @@ 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 { + 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-conflict%' + AND fb.${beads.columns.status} NOT IN ('closed', 'failed') + LIMIT 1 + `, + [mrBeadId] + ), + ]; + return rows.length > 0; +} + /** 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 = [ @@ -2261,6 +2366,45 @@ 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` with the PR URL once the push succeeds.'); + + 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/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). */