From 8730596e62c78e55cd74a91376560792f09a9c51 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Mon, 6 Apr 2026 16:21:55 -0500 Subject: [PATCH 1/2] fix(gastown): auto-merge pipeline fixes, Workers AI thread classification, and bug fixes - Add Workers AI (Gemma 4 26B) to classify unresolved PR review threads as blocking vs non-blocking for auto-merge decisions. Informational comments (LGTM, bot status reports) no longer block auto-merge. - Fix mergePR to try squash/merge/rebase in order instead of hardcoding merge method (repos with squash-only policy were failing with 405). - Fix resetAgent to also zero dispatch_attempts so agents recover immediately after container evictions instead of being stuck in exponential backoff. - Fix code_review=false bypass: fast-track ALL open MR beads (not just those with pr_url) to prevent the refinery from being dispatched for code review when code_review is disabled. - Fix cross-tick race in pr_feedback_detected: re-verify PR is still open before creating feedback beads to prevent duplicate PRs on merged branches. - Add AI binding to wrangler.jsonc for both production and dev environments. - Add diagnostic logging for poll_pr auto-merge flow (allGreen, readySince, elapsed/delay, convoy dispatch target branch). - Update local-debug-testing.md with Workers AI documentation. --- services/gastown/docs/local-debug-testing.md | 57 +++++ services/gastown/src/dos/Town.do.ts | 210 +++++++++++++++++-- services/gastown/src/dos/town/actions.ts | 113 ++++++---- services/gastown/src/dos/town/reconciler.ts | 20 +- services/gastown/worker-configuration.d.ts | 2 + services/gastown/wrangler.jsonc | 4 + 6 files changed, 333 insertions(+), 73 deletions(-) diff --git a/services/gastown/docs/local-debug-testing.md b/services/gastown/docs/local-debug-testing.md index 7d795592f0..9e69aaa438 100644 --- a/services/gastown/docs/local-debug-testing.md +++ b/services/gastown/docs/local-debug-testing.md @@ -309,3 +309,60 @@ Triage/escalation beads pile up with `rig_id=NULL`. These are by design: - GUPP force-stop beads are created by the patrol system for stuck agents During testing, container restarts generate many of these. Bulk-close via admin panel if needed. + +## 7. Auto-Merge with Workers AI Thread Classification + +The auto-merge flow uses Workers AI (Gemma 4 26B) to classify unresolved PR review threads as blocking vs non-blocking. This prevents informational bot comments (status reports, code review summaries) from blocking auto-merge. + +### How It Works + +1. `poll_pr` runs every ~60s for MR beads with a `pr_url` +2. `checkPRFeedback` fetches review threads via GitHub GraphQL (including comment bodies) +3. If unresolved threads exist, `areThreadsBlocking()` sends them to Workers AI +4. The model classifies threads as BLOCKING (requires code changes, bugs, security) or NON-BLOCKING (informational, nits, bot status reports) +5. Only truly blocking threads prevent auto-merge + +### Config Required + +Set these on the town config (via `PATCH /debug/towns/:townId/config`): + +```json +{ + "refinery": { + "auto_merge": true, + "auto_merge_delay_minutes": 0, + "auto_resolve_pr_feedback": true + } +} +``` + +### Testing Locally + +The `areThreadsBlocking` AI call only triggers when: + +- An MR bead has a `pr_url` (external GitHub PR exists) +- The PR has unresolved review threads on GitHub +- `auto_merge` is enabled in the town config + +In local dev, PRs created by the refinery in review-and-merge mode may not create external GitHub PRs, so the AI classification branch won't fire. To test the AI path end-to-end: + +1. Manually create a PR with unresolved review comments on the target repo +2. Create an MR bead that references that PR +3. Watch the wrangler logs for `areThreadsBlocking` output + +### Monitoring in Production + +Query Analytics Engine for the `areThreadsBlocking` log output: + +```bash +# Check wrangler tail for AI classification logs +npx wrangler tail gastown --format json --search "areThreadsBlocking" +``` + +The `areThreadsBlocking` method logs its decision: + +``` +[town] areThreadsBlocking: blocking=false reason= threads=2 +``` + +If the AI call fails, it conservatively defaults to `blocking=true` and logs a warning. diff --git a/services/gastown/src/dos/Town.do.ts b/services/gastown/src/dos/Town.do.ts index 1c5655495a..138770abba 100644 --- a/services/gastown/src/dos/Town.do.ts +++ b/services/gastown/src/dos/Town.do.ts @@ -326,6 +326,10 @@ export class TownDO extends DurableObject { ? convoyFeatureBranch : (rig?.default_branch ?? 'main'); + console.log( + `${TOWN_LOG} dispatch polecat: bead=${beadId} convoyId=${convoyId ?? 'none'} mergeMode=${convoyMergeMode ?? 'none'} featureBranch=${convoyFeatureBranch ?? 'none'} targetBranch=${targetBranch}` + ); + systemPromptOverride = buildPolecatSystemPrompt({ agentName: agent.name, rigId, @@ -1134,6 +1138,18 @@ export class TownDO extends DurableObject { agents.updateAgentStatus(this.sql, agentId, 'idle'); + // Reset dispatch_attempts so the reconciler will dispatch this agent + // immediately on the next tick instead of waiting for cooldown/backoff. + query( + this.sql, + /* sql */ ` + UPDATE ${agent_metadata} + SET ${agent_metadata.columns.dispatch_attempts} = 0 + WHERE ${agent_metadata.bead_id} = ? + `, + [agentId] + ); + console.log( `${TOWN_LOG} resetAgent: reset agent=${agentId} hookedBead=${hookedBeadId ?? 'none'}` ); @@ -4156,9 +4172,11 @@ export class TownDO extends DurableObject { }; // Check for unresolved review threads via GraphQL. - // Fetches the first 100 threads; if there are more (hasNextPage), - // conservatively treat the PR as having unresolved comments to avoid - // auto-merging with un-checked reviewer feedback. + // Fetches the first 100 threads with comment bodies; if there are more + // (hasNextPage), conservatively treat the PR as having unresolved comments. + // When unresolved threads exist, uses Workers AI to classify whether they + // are truly blocking (requesting code changes, identifying bugs) vs + // informational (status reports, nits, praise, warnings about future work). let hasUnresolvedComments = false; try { const graphqlRes = await fetch('https://api.github.com/graphql', { @@ -4170,7 +4188,15 @@ export class TownDO extends DurableObject { pullRequest(number: $number) { reviewThreads(first: 100) { pageInfo { hasNextPage } - nodes { isResolved } + nodes { + isResolved + comments(first: 5) { + nodes { + body + author { login } + } + } + } } } } @@ -4191,7 +4217,21 @@ export class TownDO extends DurableObject { reviewThreads: z .object({ pageInfo: z.object({ hasNextPage: z.boolean() }).optional(), - nodes: z.array(z.object({ isResolved: z.boolean() })), + nodes: z.array( + z.object({ + isResolved: z.boolean(), + comments: z + .object({ + nodes: z.array( + z.object({ + body: z.string(), + author: z.object({ login: z.string() }).nullable(), + }) + ), + }) + .optional(), + }) + ), }) .optional(), }) @@ -4207,7 +4247,16 @@ export class TownDO extends DurableObject { : undefined; const threads = reviewThreads?.nodes ?? []; const hasMorePages = reviewThreads?.pageInfo?.hasNextPage === true; - hasUnresolvedComments = threads.some(t => !t.isResolved) || hasMorePages; + + if (hasMorePages) { + // Too many threads to inspect — conservatively block auto-merge + hasUnresolvedComments = true; + } else { + const unresolvedThreads = threads.filter(t => !t.isResolved); + if (unresolvedThreads.length > 0) { + hasUnresolvedComments = await this.areThreadsBlocking(unresolvedThreads); + } + } } } catch (err) { console.warn(`${TOWN_LOG} checkPRFeedback: GraphQL failed for ${prUrl}`, err); @@ -4312,6 +4361,112 @@ export class TownDO extends DurableObject { return { hasUnresolvedComments, hasFailingChecks, allChecksPass, hasUncheckedRuns }; } + /** + * Use Workers AI to determine if unresolved PR review threads contain + * blocking feedback that should prevent auto-merge. + * + * Returns true if any thread is blocking (requires code changes, identifies + * bugs/security issues). Returns false if all threads are informational + * (status reports, nits, praise, warnings about future considerations). + * + * Falls back to true (conservatively blocking) if the AI call fails. + */ + private async areThreadsBlocking( + threads: Array<{ + isResolved: boolean; + comments?: { nodes: Array<{ body: string; author: { login: string } | null }> }; + }> + ): Promise { + try { + const threadSummaries = threads.map((t, i) => { + const comments = t.comments?.nodes ?? []; + const commentText = comments + .map(c => ` [${c.author?.login ?? 'unknown'}]: ${c.body}`) + .join('\n'); + return `Thread ${i + 1}:\n${commentText}`; + }); + + const prompt = `You are evaluating unresolved PR review comment threads to decide if a pull request is safe to auto-merge. + +Here are the unresolved review threads: + +${threadSummaries.join('\n\n')} + +For each thread, classify it as BLOCKING or NON-BLOCKING: +- BLOCKING: Requests a code change, identifies a bug, security vulnerability, correctness problem, or raises a warning about the code that should be addressed before merge. +- NON-BLOCKING: Approvals, praise, "LGTM", status summaries (e.g. "Code review passed", "No issues found"), acknowledgements, or comments that express approval of the code without requesting changes. + +Important: A comment is only NON-BLOCKING if it expresses approval or is purely a status report. If a comment raises any concern, warning, suggestion, or question about the code — even if phrased softly — it is BLOCKING. + +Respond with ONLY a JSON object (no markdown, no explanation): { "blocking": true/false, "reason": "brief one-sentence explanation" }`; + + const response: unknown = await this.env.AI.run( + // Model may not be in the AiModels type map yet — cast to access it. + '@cf/google/gemma-4-26b-a4b-it' as keyof AiModels, + { + messages: [{ role: 'user', content: prompt }], + max_tokens: 256, + temperature: 0, + // Disable Gemma 4's thinking/reasoning mode — we only need the + // final JSON answer, not a chain-of-thought. Without this, the + // model burns all tokens on reasoning and returns content: null. + chat_template_kwargs: { enable_thinking: false }, + } as AiTextGenerationInput + ); + + // Gemma 4 returns OpenAI-compatible chat completion format: + // { choices: [{ message: { content: "..." } }] } + // Older Workers AI models return { response: "..." }. + // Parse both shapes defensively. + const openAiResult = z + .object({ + choices: z.array(z.object({ message: z.object({ content: z.string() }) })), + }) + .safeParse(response); + const legacyResult = z.object({ response: z.string() }).safeParse(response); + + const text = openAiResult.success + ? openAiResult.data.choices[0]?.message.content + : legacyResult.success + ? legacyResult.data.response + : null; + if (!text) { + console.warn( + `${TOWN_LOG} areThreadsBlocking: could not extract text from AI response, defaulting to blocking. Raw: ${JSON.stringify(response)?.slice(0, 500)}` + ); + return true; + } + + // Extract JSON from response (model may wrap in markdown code fences) + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + console.warn( + `${TOWN_LOG} areThreadsBlocking: no JSON in AI response, defaulting to blocking: ${text}` + ); + return true; + } + + const parsed = z + .object({ blocking: z.boolean(), reason: z.string().optional() }) + .safeParse(JSON.parse(jsonMatch[0])); + + if (!parsed.success) { + console.warn( + `${TOWN_LOG} areThreadsBlocking: failed to parse AI response, defaulting to blocking: ${text}` + ); + return true; + } + + console.log( + `${TOWN_LOG} areThreadsBlocking: blocking=${parsed.data.blocking} reason=${parsed.data.reason ?? 'none'} threads=${threads.length}` + ); + return parsed.data.blocking; + } catch (err) { + console.warn(`${TOWN_LOG} areThreadsBlocking: AI call failed, defaulting to blocking`, err); + return true; + } + } + /** * Merge a PR via GitHub API. Used by the auto-merge feature. * Returns true if the merge succeeded. @@ -4330,29 +4485,42 @@ export class TownDO extends DurableObject { return false; } - const response = await fetch( - `https://api.github.com/repos/${owner}/${repo}/pulls/${numberStr}/merge`, - { + const mergeUrl = `https://api.github.com/repos/${owner}/${repo}/pulls/${numberStr}/merge`; + const mergeHeaders = { + Authorization: `token ${token}`, + Accept: 'application/vnd.github.v3+json', + 'Content-Type': 'application/json', + 'User-Agent': 'Gastown-Refinery/1.0', + }; + + // Try merge methods in order of preference. Repos may only allow a subset + // (e.g. squash-only). A 405 "not allowed" response means that method is + // disabled — try the next one. + const methods = ['squash', 'merge', 'rebase'] as const; + for (const method of methods) { + const response = await fetch(mergeUrl, { method: 'PUT', - headers: { - Authorization: `token ${token}`, - Accept: 'application/vnd.github.v3+json', - 'Content-Type': 'application/json', - 'User-Agent': 'Gastown-Refinery/1.0', - }, - body: JSON.stringify({ merge_method: 'merge' }), - } - ); + headers: mergeHeaders, + body: JSON.stringify({ merge_method: method }), + }); + + if (response.ok) return true; - if (!response.ok) { const text = await response.text().catch(() => '(unreadable)'); + if (response.status === 405 && text.includes('not allowed')) { + // This merge method is disabled on the repo — try the next one + continue; + } + + // Any other error (409 conflict, 422 validation, etc.) is a real failure console.warn( - `${TOWN_LOG} mergePR: GitHub API returned ${response.status} for ${prUrl}: ${text.slice(0, 500)}` + `${TOWN_LOG} mergePR: GitHub API returned ${response.status} for ${prUrl} (method=${method}): ${text.slice(0, 500)}` ); return false; } - return true; + console.warn(`${TOWN_LOG} mergePR: all merge methods rejected for ${prUrl}`); + return false; } /** diff --git a/services/gastown/src/dos/town/actions.ts b/services/gastown/src/dos/town/actions.ts index 60d0cc2544..067195860e 100644 --- a/services/gastown/src/dos/town/actions.ts +++ b/services/gastown/src/dos/town/actions.ts @@ -652,49 +652,61 @@ export function applyAction(ctx: ApplyActionContext, action: Action): (() => Pro feedback.hasFailingChecks || feedback.hasUncheckedRuns) ) { - const existingFeedback = hasExistingFeedbackBead(sql, action.bead_id); - if (!existingFeedback) { - const prMeta = parsePrUrl(action.pr_url); - const rmRows = z - .object({ branch: z.string() }) - .array() - .parse([ - ...query( - sql, - /* sql */ ` - SELECT ${review_metadata.columns.branch} - FROM ${review_metadata} - WHERE ${review_metadata.bead_id} = ? - `, - [action.bead_id] - ), - ]); - const branch = rmRows[0]?.branch ?? ''; - - ctx.insertEvent('pr_feedback_detected', { - bead_id: action.bead_id, - payload: { - mr_bead_id: action.bead_id, - pr_url: action.pr_url, - pr_number: prMeta?.prNumber ?? 0, - repo: prMeta?.repo ?? '', - branch, - has_unresolved_comments: feedback.hasUnresolvedComments, - has_failing_checks: feedback.hasFailingChecks, - has_unchecked_runs: feedback.hasUncheckedRuns, - }, - }); - } + // Re-verify the PR is still open before creating a feedback bead. + // The checkPRFeedback call above takes ~2s (3+ GitHub API calls). + // 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') { + console.log( + `${LOG} poll_pr: PR status changed to '${freshStatus}' during feedback check, skipping feedback for bead=${action.bead_id}` + ); + } else { + const existingFeedback = hasExistingFeedbackBead(sql, action.bead_id); + if (!existingFeedback) { + const prMeta = parsePrUrl(action.pr_url); + const rmRows = z + .object({ branch: z.string() }) + .array() + .parse([ + ...query( + sql, + /* sql */ ` + SELECT ${review_metadata.columns.branch} + FROM ${review_metadata} + WHERE ${review_metadata.bead_id} = ? + `, + [action.bead_id] + ), + ]); + const branch = rmRows[0]?.branch ?? ''; + + ctx.insertEvent('pr_feedback_detected', { + bead_id: action.bead_id, + payload: { + mr_bead_id: action.bead_id, + pr_url: action.pr_url, + pr_number: prMeta?.prNumber ?? 0, + repo: prMeta?.repo ?? '', + branch, + has_unresolved_comments: feedback.hasUnresolvedComments, + has_failing_checks: feedback.hasFailingChecks, + has_unchecked_runs: feedback.hasUncheckedRuns, + }, + }); + } - query( - sql, - /* sql */ ` - UPDATE ${review_metadata} - SET ${review_metadata.columns.last_feedback_check_at} = ? - WHERE ${review_metadata.bead_id} = ? - `, - [now(), action.bead_id] - ); + query( + sql, + /* sql */ ` + UPDATE ${review_metadata} + SET ${review_metadata.columns.last_feedback_check_at} = ? + WHERE ${review_metadata.bead_id} = ? + `, + [now(), action.bead_id] + ); + } } // Auto-merge timer: track grace period when everything is green @@ -706,6 +718,10 @@ export function applyAction(ctx: ApplyActionContext, action: Action): (() => Pro !feedback.hasFailingChecks && feedback.allChecksPass; + console.log( + `${LOG} poll_pr: bead=${action.bead_id} allGreen=${allGreen} unresolved=${feedback.hasUnresolvedComments} failing=${feedback.hasFailingChecks} allPass=${feedback.allChecksPass} unchecked=${feedback.hasUncheckedRuns}` + ); + if (allGreen) { const readySinceRows = z .object({ auto_merge_ready_since: z.string().nullable() }) @@ -724,6 +740,10 @@ export function applyAction(ctx: ApplyActionContext, action: Action): (() => Pro const readySince = readySinceRows[0]?.auto_merge_ready_since; + console.log( + `${LOG} poll_pr: bead=${action.bead_id} readySince=${readySince ?? 'null'} rows=${readySinceRows.length}` + ); + if (!readySince) { query( sql, @@ -736,7 +756,14 @@ export function applyAction(ctx: ApplyActionContext, action: Action): (() => Pro ); } else { const elapsed = Date.now() - new Date(readySince).getTime(); - if (elapsed >= (refineryConfig.auto_merge_delay_minutes ?? 0) * 60_000) { + const delayMs = (refineryConfig.auto_merge_delay_minutes ?? 0) * 60_000; + console.log( + `${LOG} poll_pr: bead=${action.bead_id} elapsed=${elapsed}ms delay=${delayMs}ms shouldMerge=${elapsed >= delayMs}` + ); + if (elapsed >= delayMs) { + console.log( + `${LOG} poll_pr: inserting pr_auto_merge event for bead=${action.bead_id}` + ); ctx.insertEvent('pr_auto_merge', { bead_id: action.bead_id, payload: { diff --git a/services/gastown/src/dos/town/reconciler.ts b/services/gastown/src/dos/town/reconciler.ts index ab4bd06bfd..9e61229d34 100644 --- a/services/gastown/src/dos/town/reconciler.ts +++ b/services/gastown/src/dos/town/reconciler.ts @@ -1160,12 +1160,17 @@ export function reconcileReviewQueue( } } - // When refinery code review is disabled, fast-track open MR beads that - // have a pr_url to in_progress so poll_pr handles them. MR beads without - // pr_url (direct merge strategy) still need the refinery for the merge - // operation itself — Rules 5-6 handle those. + // When refinery code review is disabled, fast-track ALL open MR beads + // to in_progress so poll_pr handles them once pr_url is populated. + // This must include beads without pr_url yet (timing window between + // review_submitted and the polecat setting pr_url) to prevent Rules 5-6 + // from dispatching the refinery for a code review that shouldn't happen. + // + // Direct-merge strategy MR beads (no pr_url, refinery merges itself) + // still need the refinery — but those are only created when + // merge_strategy='direct', which inherently requires the refinery. if (!refineryCodeReview) { - const openMrsWithPr = z + const openMrs = z .object({ bead_id: z.string() }) .array() .parse([ @@ -1174,16 +1179,13 @@ export function reconcileReviewQueue( /* sql */ ` SELECT b.${beads.columns.bead_id} FROM ${beads} b - INNER JOIN ${review_metadata} rm - ON rm.${review_metadata.columns.bead_id} = b.${beads.columns.bead_id} WHERE b.${beads.columns.type} = 'merge_request' AND b.${beads.columns.status} = 'open' - AND rm.${review_metadata.columns.pr_url} IS NOT NULL `, [] ), ]); - for (const { bead_id } of openMrsWithPr) { + for (const { bead_id } of openMrs) { actions.push({ type: 'transition_bead', bead_id, diff --git a/services/gastown/worker-configuration.d.ts b/services/gastown/worker-configuration.d.ts index 1acac8ca52..a58bfe9616 100644 --- a/services/gastown/worker-configuration.d.ts +++ b/services/gastown/worker-configuration.d.ts @@ -39,6 +39,7 @@ declare namespace Cloudflare { TOWN_CONTAINER: DurableObjectNamespace; AGENT: DurableObjectNamespace; GIT_TOKEN_SERVICE: GitTokenService; + AI: Ai; } interface Env { GASTOWN_AE: AnalyticsEngineDataset; @@ -56,6 +57,7 @@ declare namespace Cloudflare { TOWN_CONTAINER: DurableObjectNamespace; AGENT: DurableObjectNamespace; GIT_TOKEN_SERVICE: GitTokenService; + AI: Ai; HYPERDRIVE?: Hyperdrive; CF_VERSION_METADATA?: WorkerVersionMetadata; SENTRY_DSN?: string; // worker secret diff --git a/services/gastown/wrangler.jsonc b/services/gastown/wrangler.jsonc index f045ffd723..54ef41f5ec 100644 --- a/services/gastown/wrangler.jsonc +++ b/services/gastown/wrangler.jsonc @@ -82,6 +82,8 @@ }, ], + "ai": { "binding": "AI" }, + "analytics_engine_datasets": [{ "binding": "GASTOWN_AE", "dataset": "gastown_events" }], "services": [ @@ -145,6 +147,8 @@ "entrypoint": "GitTokenRPCEntrypoint", }, ], + "ai": { "binding": "AI" }, + "analytics_engine_datasets": [{ "binding": "GASTOWN_AE", "dataset": "gastown_events" }], "secrets_store_secrets": [ { From 256bdcf01001de3141c693986ec189d24e375304 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Mon, 6 Apr 2026 19:45:39 -0500 Subject: [PATCH 2/2] feat(gastown): custom per-role prompt instructions in town settings Migrated from pre-monorepo branch (1794-agent-prompts) to new monorepo structure (services/gastown/, apps/web/). - Add custom_instructions field to town config schema (polecat, refinery, mayor) - Add Custom Instructions section to town settings UI with per-role textareas - Instructions appended via appendCustomInstructions() in dispatch path - Mayor prompt updates rewrite AGENTS.md via PUT /agents/:agentId/system-prompt - Deep-merge custom_instructions per-role in updateTownConfig - Propagate mayor instructions from both tRPC and HTTP admin routes - Widen all gastown drawers ~120px, remove truncate from drawer titles - Regenerate gastown tRPC type declarations Closes #1794 --- .../settings/TownSettingsPageClient.tsx | 50 +++++++++++++++- .../components/gastown/AgentDetailDrawer.tsx | 2 +- .../components/gastown/BeadDetailDrawer.tsx | 4 +- .../src/components/gastown/DrawerStack.tsx | 2 +- .../components/gastown/EventDetailDrawer.tsx | 2 +- .../gastown/GastownBeadDetailSheet.tsx | 6 +- .../gastown/drawer-panels/BeadPanel.tsx | 2 +- apps/web/src/lib/gastown/types/router.d.ts | 42 +++++++++++++ apps/web/src/routers/admin/gastown-router.ts | 7 +++ .../gastown/container/src/agent-runner.ts | 2 +- .../gastown/container/src/control-server.ts | 25 +++++++- services/gastown/src/dos/Town.do.ts | 43 ++++++++++++++ services/gastown/src/dos/town/config.ts | 17 ++++++ .../src/dos/town/container-dispatch.ts | 59 ++++++++++++++++--- .../src/handlers/town-config.handler.ts | 11 ++++ services/gastown/src/trpc/router.ts | 12 ++++ services/gastown/src/types.ts | 16 +++++ 17 files changed, 280 insertions(+), 22 deletions(-) diff --git a/apps/web/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx b/apps/web/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx index 5b3d6a234f..3fe5abdf28 100644 --- a/apps/web/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx +++ b/apps/web/src/app/(app)/gastown/[townId]/settings/TownSettingsPageClient.tsx @@ -31,6 +31,7 @@ import { Container, User, Key, + MessageSquareText, X, } from 'lucide-react'; import { @@ -40,6 +41,7 @@ import { AccordionContent, } from '@/components/ui/accordion'; import { Slider } from '@/components/ui/slider'; +import { Textarea } from '@/components/ui/textarea'; import { motion } from 'motion/react'; import { AdminViewingBanner } from '@/components/gastown/AdminViewingBanner'; import { useRouter } from 'next/navigation'; @@ -71,6 +73,7 @@ const SECTIONS = [ { id: 'merge-strategy', label: 'Merge Strategy', icon: GitPullRequest }, { id: 'refinery', label: 'Refinery', icon: Shield }, { id: 'container', label: 'Container', icon: Container }, + { id: 'custom-instructions', label: 'Custom Instructions', icon: MessageSquareText }, { id: 'danger-zone', label: 'Danger Zone', icon: Trash2 }, ] as const; @@ -280,6 +283,9 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI const [gitAuthorName, setGitAuthorName] = useState(''); const [gitAuthorEmail, setGitAuthorEmail] = useState(''); const [disableAiCoauthor, setDisableAiCoauthor] = useState(false); + const [polecatInstructions, setPolecatInstructions] = useState(''); + const [refineryInstructions, setRefineryInstructions] = useState(''); + const [mayorInstructions, setMayorInstructions] = useState(''); const [initialized, setInitialized] = useState(false); const [showTokens, setShowTokens] = useState(false); @@ -316,6 +322,9 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI setGitAuthorName(cfg.git_author_name ?? ''); setGitAuthorEmail(cfg.git_author_email ?? ''); setDisableAiCoauthor(cfg.disable_ai_coauthor ?? false); + setPolecatInstructions(cfg.custom_instructions?.polecat ?? ''); + setRefineryInstructions(cfg.custom_instructions?.refinery ?? ''); + setMayorInstructions(cfg.custom_instructions?.mayor ?? ''); setInitialized(true); } @@ -366,6 +375,11 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI auto_merge_delay_minutes: autoMergeDelayMinutes, }, convoy_merge_mode: convoyMergeMode, + custom_instructions: { + polecat: polecatInstructions || undefined, + refinery: refineryInstructions || undefined, + mayor: mayorInstructions || undefined, + }, }, }); } @@ -1057,13 +1071,47 @@ export function TownSettingsPageClient({ townId, readOnly = false, organizationI + {/* ── Custom Instructions ────────────────────────────────── */} + +
+ {( + [ + ['Polecat Instructions', polecatInstructions, setPolecatInstructions], + ['Refinery Instructions', refineryInstructions, setRefineryInstructions], + ['Mayor Instructions', mayorInstructions, setMayorInstructions], + ] as const + ).map(([roleLabel, value, setValue]) => ( + +
+