From d5d05465699ac39ce768c481ed6fd18a8f225243 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:58:50 +0000 Subject: [PATCH 1/4] feat(claw): evaluate button-vs-card feature flag for PostHog experiment tracking --- src/app/(app)/claw/components/ClawDashboard.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/(app)/claw/components/ClawDashboard.tsx b/src/app/(app)/claw/components/ClawDashboard.tsx index 8a221da984..446d59fb2a 100644 --- a/src/app/(app)/claw/components/ClawDashboard.tsx +++ b/src/app/(app)/claw/components/ClawDashboard.tsx @@ -25,6 +25,7 @@ import { PermissionStep } from './PermissionStep'; import { ProvisioningStep } from './ProvisioningStep'; import type { ExecPreset } from './claw.types'; import { BillingWrapper } from './billing/BillingWrapper'; +import { useFeatureFlagVariantKey } from 'posthog-js/react'; type PopulatedClawStatus = KiloClawDashboardStatus & { status: NonNullable; @@ -45,6 +46,8 @@ export function ClawDashboard({ isNewSetup: boolean; onNewSetupChange: (v: boolean) => void; }) { + useFeatureFlagVariantKey('button-vs-card'); // evaluate so PostHog attaches $feature/button-vs-card to subsequent events + const mutations = useKiloClawMutations(); const gatewayUrl = useGatewayUrl(status); const instanceStatus = hasPopulatedStatus(status) ? status : null; From 4c75b0bdfb9c80d0068062431f4a8a70d32221a3 Mon Sep 17 00:00:00 2001 From: Pedro Heyerdahl Date: Fri, 20 Mar 2026 17:02:22 -0300 Subject: [PATCH 2/4] fix(claw): move button-vs-card flag eval to CreateInstanceCard Moves useFeatureFlagVariantKey('button-vs-card') from ClawDashboard (which renders for all users including those with existing instances) to CreateInstanceCard (which only renders for users who haven't provisioned yet). This scopes the experiment exposure to users who can actually see the create CTA, avoiding population dilution. --- src/app/(app)/claw/components/ClawDashboard.tsx | 3 --- src/app/(app)/claw/components/CreateInstanceCard.tsx | 5 ++++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/(app)/claw/components/ClawDashboard.tsx b/src/app/(app)/claw/components/ClawDashboard.tsx index 446d59fb2a..8a221da984 100644 --- a/src/app/(app)/claw/components/ClawDashboard.tsx +++ b/src/app/(app)/claw/components/ClawDashboard.tsx @@ -25,7 +25,6 @@ import { PermissionStep } from './PermissionStep'; import { ProvisioningStep } from './ProvisioningStep'; import type { ExecPreset } from './claw.types'; import { BillingWrapper } from './billing/BillingWrapper'; -import { useFeatureFlagVariantKey } from 'posthog-js/react'; type PopulatedClawStatus = KiloClawDashboardStatus & { status: NonNullable; @@ -46,8 +45,6 @@ export function ClawDashboard({ isNewSetup: boolean; onNewSetupChange: (v: boolean) => void; }) { - useFeatureFlagVariantKey('button-vs-card'); // evaluate so PostHog attaches $feature/button-vs-card to subsequent events - const mutations = useKiloClawMutations(); const gatewayUrl = useGatewayUrl(status); const instanceStatus = hasPopulatedStatus(status) ? status : null; diff --git a/src/app/(app)/claw/components/CreateInstanceCard.tsx b/src/app/(app)/claw/components/CreateInstanceCard.tsx index 29b330c9c0..a48c545845 100644 --- a/src/app/(app)/claw/components/CreateInstanceCard.tsx +++ b/src/app/(app)/claw/components/CreateInstanceCard.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { usePostHog } from 'posthog-js/react'; +import { useFeatureFlagVariantKey, usePostHog } from 'posthog-js/react'; import { useQuery } from '@tanstack/react-query'; import { toast } from 'sonner'; import type { useKiloClawMutations } from '@/hooks/useKiloClaw'; @@ -25,6 +25,9 @@ export function CreateInstanceCard({ mutations: ClawMutations; onProvisionStart?: () => void; }) { + // Evaluate the landing-page experiment flag so PostHog attaches + // $feature/button-vs-card to events fired in this component. + useFeatureFlagVariantKey('button-vs-card'); const posthog = usePostHog(); const trpc = useTRPC(); const { data: billingStatus } = useQuery(trpc.kiloclaw.getBillingStatus.queryOptions()); From af17a154d1faf1eac5e5fcfb27f292f2efc60969 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 21 Mar 2026 15:56:19 +0000 Subject: [PATCH 3/4] feat(gastown): add POST /debug/reconcile-dry-run endpoint Add a debug endpoint that runs the reconciler against current live state and returns the actions it would emit without applying them. This enables inspecting what the reconciler thinks should happen at any given moment. - Add debugDryRun() method to TownDO that calls reconciler.reconcile() and returns actions + metrics without calling applyAction() - Add POST /debug/towns/:townId/reconcile-dry-run route following the same unauthenticated debug pattern as GET /debug/towns/:townId/status - Response includes actions array, actionsEmitted count, actionsByType breakdown, and pendingEventCount --- cloudflare-gastown/src/dos/Town.do.ts | 27 +++++++++++++++++++++++- cloudflare-gastown/src/gastown.worker.ts | 8 +++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 0cf7c974a7..6fdcc33692 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -30,7 +30,7 @@ import * as scheduling from './town/scheduling'; import * as events from './town/events'; import * as reconciler from './town/reconciler'; import { applyAction } from './town/actions'; -import type { ApplyActionContext } from './town/actions'; +import type { Action, ApplyActionContext } from './town/actions'; import { buildRefinerySystemPrompt } from '../prompts/refinery-system.prompt'; import { GitHubPRStatusSchema, GitLabMRStatusSchema } from '../util/platform-pr.util'; @@ -3684,6 +3684,31 @@ export class TownDO extends DurableObject { }; } + // DEBUG: dry-run the reconciler against current state, returning actions + // it would emit without applying them. Side-effect-free — reconcile() + // only reads SQLite state; applyAction() is never called. + async debugDryRun(): Promise<{ + actions: Action[]; + metrics: Pick< + reconciler.ReconcilerMetrics, + 'actionsEmitted' | 'actionsByType' | 'pendingEventCount' + >; + }> { + const actions = reconciler.reconcile(this.sql); + const actionsByType: Record = {}; + for (const a of actions) { + actionsByType[a.type] = (actionsByType[a.type] ?? 0) + 1; + } + return { + actions, + metrics: { + actionsEmitted: actions.length, + actionsByType, + pendingEventCount: events.pendingEventCount(this.sql), + }, + }; + } + // DEBUG: concise non-terminal bead summary — remove after debugging async debugBeadSummary(): Promise { return [ diff --git a/cloudflare-gastown/src/gastown.worker.ts b/cloudflare-gastown/src/gastown.worker.ts index 6c8efab713..1a1634f0d2 100644 --- a/cloudflare-gastown/src/gastown.worker.ts +++ b/cloudflare-gastown/src/gastown.worker.ts @@ -206,6 +206,14 @@ app.get('/debug/towns/:townId/status', async c => { return c.json({ alarmStatus, agentMeta, beadSummary }); }); +app.post('/debug/towns/:townId/reconcile-dry-run', async c => { + const townId = c.req.param('townId'); + const town = getTownDOStub(c.env, townId); + // eslint-disable-next-line @typescript-eslint/await-thenable -- DO RPC returns promise at runtime + const result = await town.debugDryRun(); + return c.json(result); +}); + // ── Town ID + Auth ────────────────────────────────────────────────────── // All rig routes live under /api/towns/:townId/rigs/:rigId so the townId // is always available from the URL path. From 26c6fc458741c5b1ab340ac393335367c86093b4 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Sat, 21 Mar 2026 16:30:57 +0000 Subject: [PATCH 4/4] fix(gastown): drain pending events in debugDryRun() before reconciling Wrap debugDryRun() in a SQLite savepoint so it can drain and apply pending town_events (Phase 0) before running reconcile (Phase 1), matching the real alarm loop behavior. The savepoint is rolled back in a finally block so the endpoint remains fully side-effect-free. Adds eventsDrained to the returned metrics. --- cloudflare-gastown/src/dos/Town.do.ts | 51 +++++++++++++++++++-------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/cloudflare-gastown/src/dos/Town.do.ts b/cloudflare-gastown/src/dos/Town.do.ts index 6fdcc33692..6de90fd56f 100644 --- a/cloudflare-gastown/src/dos/Town.do.ts +++ b/cloudflare-gastown/src/dos/Town.do.ts @@ -3685,28 +3685,49 @@ export class TownDO extends DurableObject { } // DEBUG: dry-run the reconciler against current state, returning actions - // it would emit without applying them. Side-effect-free — reconcile() - // only reads SQLite state; applyAction() is never called. + // it would emit without applying them. Drains pending events first (same + // as the real alarm loop) inside a savepoint that is rolled back, so the + // endpoint remains fully side-effect-free. async debugDryRun(): Promise<{ actions: Action[]; metrics: Pick< reconciler.ReconcilerMetrics, - 'actionsEmitted' | 'actionsByType' | 'pendingEventCount' + 'actionsEmitted' | 'actionsByType' | 'pendingEventCount' | 'eventsDrained' >; }> { - const actions = reconciler.reconcile(this.sql); - const actionsByType: Record = {}; - for (const a of actions) { - actionsByType[a.type] = (actionsByType[a.type] ?? 0) + 1; + // Use a savepoint so we can drain events (which mutates state) + // then roll back without permanent side effects + this.sql.exec('SAVEPOINT debug_dry_run'); + try { + // Phase 0: Drain and apply pending events (same as real alarm loop) + const pending = events.drainEvents(this.sql); + for (const event of pending) { + reconciler.applyEvent(this.sql, event); + events.markProcessed(this.sql, event.event_id); + } + + // Phase 1: Reconcile against now-current state + const actions = reconciler.reconcile(this.sql); + const pendingEventCount = events.pendingEventCount(this.sql); + const actionsByType: Record = {}; + for (const a of actions) { + actionsByType[a.type] = (actionsByType[a.type] ?? 0) + 1; + } + + return { + actions, + metrics: { + actionsEmitted: actions.length, + actionsByType, + pendingEventCount, + eventsDrained: pending.length, + }, + }; + } finally { + // Roll back all state mutations — this is a dry run + this.sql.exec('ROLLBACK TO SAVEPOINT debug_dry_run'); + this.sql.exec('RELEASE SAVEPOINT debug_dry_run'); } - return { - actions, - metrics: { - actionsEmitted: actions.length, - actionsByType, - pendingEventCount: events.pendingEventCount(this.sql), - }, - }; } // DEBUG: concise non-terminal bead summary — remove after debugging