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
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
125 changes: 93 additions & 32 deletions services/gastown/src/dos/town/reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,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(
Expand Down Expand Up @@ -482,6 +503,28 @@ export function applyEvent(sql: SqlStorage, event: TownEventRecord): void {
: true;

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}`,
Expand Down Expand Up @@ -2250,42 +2293,60 @@ function hasRecentNudge(sql: SqlStorage, agentId: string, tier: string): boolean

/** 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;
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. */
Expand Down
25 changes: 24 additions & 1 deletion services/gastown/src/prompts/polecat-system.prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,29 @@ 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 <branch>\`
2. Rebase onto the target branch to incorporate its latest changes:
```
git rebase origin/<target_branch>
```
3. If there are conflicts during rebase, resolve them:
- Edit conflicting files to resolve conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`)
- Stage the resolved files: \`git add <file>\`
- Continue the rebase: \`git rebase --continue\`
- Repeat until the rebase completes
4. Push the rebased branch:
```
git push --force-with-lease origin <branch>
```
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 the PR URL once all conflicts are resolved (and feedback addressed if applicable).

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.
Expand All @@ -101,7 +124,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

Expand Down
8 changes: 8 additions & 0 deletions services/gastown/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 --
Expand Down