Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions services/gastown/src/db/tables/town-events.table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof TownEventType>;
Expand Down
118 changes: 111 additions & 7 deletions services/gastown/src/dos/town/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────

Expand Down Expand Up @@ -279,8 +280,8 @@ export type ApplyActionContext = {
dispatchAgent: (agentId: string, beadId: string, rigId: string) => Promise<boolean>;
/** Stop an agent's container process. */
stopAgent: (agentId: string) => Promise<void>;
/** 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<PRStatusResult | null>;
/** Check PR for unresolved review comments and failing CI checks. */
checkPRFeedback: (prUrl: string) => Promise<PRFeedbackCheckResult | null>;
/** Merge a PR via GitHub/GitLab API. */
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -752,6 +754,108 @@ export function applyAction(ctx: ApplyActionContext, action: Action): (() => Pro
const refineryConfig = townConfig.refinery;
if (!refineryConfig) return;

if (mergeable_state === 'dirty') {
Comment thread
jrf0110 marked this conversation as resolved.
// 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 &&
Expand All @@ -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);
Expand Down
144 changes: 144 additions & 0 deletions services/gastown/src/dos/town/reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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 <file>`');
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.
Expand Down
10 changes: 8 additions & 2 deletions services/gastown/src/dos/town/review-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading