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
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ export function RigSettingsPageClient({ townId, rigId, organizationId }: Props)
const [autoResolvePrFeedback, setAutoResolvePrFeedback] = useState<boolean | undefined>(
undefined
);
const [autoResolveMergeConflicts, setAutoResolveMergeConflicts] = useState<boolean | undefined>(
undefined
);
const [autoMergeDelayMinutes, setAutoMergeDelayMinutes] = useState<number | null | undefined>(
undefined
);
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -505,6 +510,46 @@ export function RigSettingsPageClient({ townId, rigId, organizationId }: Props)
</div>
</div>

<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex-1">
<Label className="text-sm text-white/70">
Auto-resolve merge conflicts
</Label>
<p className="text-[11px] text-white/30">
When a PR has merge conflicts, automatically dispatch an agent to rebase
and resolve them.
{townCfg?.refinery?.auto_resolve_merge_conflicts !== undefined && (
<span className="ml-1 text-white/20">
(Town default:{' '}
{townCfg.refinery.auto_resolve_merge_conflicts ? 'on' : 'off'})
</span>
)}
</p>
</div>
<div className="flex items-center gap-2">
{autoResolveMergeConflicts !== undefined && (
<button
onClick={() => setAutoResolveMergeConflicts(undefined)}
className="rounded p-1 text-white/25 transition-colors hover:bg-white/[0.06] hover:text-white/50"
title="Inherit from town"
>
<X className="size-3" />
</button>
)}
<Switch
checked={
autoResolveMergeConflicts ??
townCfg?.refinery?.auto_resolve_merge_conflicts ??
true
}
onCheckedChange={v => setAutoResolveMergeConflicts(v)}
className={autoResolveMergeConflicts === undefined ? 'opacity-40' : ''}
/>
</div>
</div>
</div>

<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-4 py-3">
<Label className="mb-1.5 block text-sm text-white/70">
Auto-merge delay (minutes)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI
const [refineryCodeReview, setRefineryCodeReview] = useState(true);
const [reviewMode, setReviewMode] = useState<'rework' | 'comments'>('rework');
const [autoResolvePrFeedback, setAutoResolvePrFeedback] = useState(false);
const [autoResolveMergeConflicts, setAutoResolveMergeConflicts] = useState(true);
const [autoMergeDelayMinutes, setAutoMergeDelayMinutes] = useState<number | null>(null);
const [mergeStrategy, setMergeStrategy] = useState<'direct' | 'pr'>('direct');
const [stagedConvoysDefault, setStagedConvoysDefault] = useState(false);
Expand Down Expand Up @@ -315,6 +316,7 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI
setRefineryCodeReview(cfg.refinery?.code_review ?? true);
setReviewMode(cfg.refinery?.review_mode === 'comments' ? 'comments' : 'rework');
setAutoResolvePrFeedback(cfg.refinery?.auto_resolve_pr_feedback ?? false);
setAutoResolveMergeConflicts(cfg.refinery?.auto_resolve_merge_conflicts ?? true);
setAutoMergeDelayMinutes(cfg.refinery?.auto_merge_delay_minutes ?? null);
setMergeStrategy(cfg.merge_strategy === 'pr' ? 'pr' : 'direct');
setStagedConvoysDefault(cfg.staged_convoys_default ?? false);
Expand Down Expand Up @@ -380,6 +382,7 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI
review_mode: reviewMode,
require_clean_merge: true,
auto_resolve_pr_feedback: autoResolvePrFeedback,
auto_resolve_merge_conflicts: autoResolveMergeConflicts,
auto_merge_delay_minutes: autoMergeDelayMinutes,
},
convoy_merge_mode: convoyMergeMode,
Expand Down Expand Up @@ -1074,6 +1077,20 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI
/>
</div>

<div className="mt-3 flex items-center gap-3 rounded-lg border border-white/[0.06] bg-white/[0.02] px-4 py-3">
<div className="flex-1">
<Label className="text-sm text-white/70">Auto-resolve merge conflicts</Label>
<p className="text-[11px] text-white/30">
When a PR has merge conflicts, automatically dispatch an agent to rebase and
resolve them.
</p>
</div>
<Switch
checked={autoResolveMergeConflicts}
onCheckedChange={setAutoResolveMergeConflicts}
/>
</div>

{autoResolvePrFeedback && (
<div className="mt-3 rounded-lg border border-white/[0.06] bg-white/[0.02] px-4 py-3">
<Label className="mb-1.5 block text-sm text-white/70">Auto-merge delay</Label>
Expand Down
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
8 changes: 6 additions & 2 deletions services/gastown/src/dos/Town.do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3673,6 +3673,11 @@ export class TownDO extends DurableObject<Env> {
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);
Expand All @@ -3682,7 +3687,7 @@ export class TownDO extends DurableObject<Env> {
}
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', {
Expand Down Expand Up @@ -3723,7 +3728,6 @@ export class TownDO extends DurableObject<Env> {
// Phase 1: Reconcile — compute desired state vs actual state
const sideEffects: Array<() => Promise<void>> = [];
try {
const townConfig = await this.getTownConfig();
const actions = reconciler.reconcile(this.sql, {
draining: this._draining,
townConfig,
Expand Down
134 changes: 127 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,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 &&
Expand All @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions services/gastown/src/dos/town/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,13 +519,41 @@ 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<string, unknown>;
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<string, unknown>;
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,
undelivered_mail: undeliveredMail,
open_beads: openBeads,
rework_context,
pr_fixup_context,
pr_conflict_context,
};
}

Expand Down
Loading