Skip to content
4 changes: 2 additions & 2 deletions cloudflare-gastown/container/src/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,8 +439,8 @@ export async function runAgent(originalRequest: StartAgentRequest): Promise<Mana
let request = originalRequest;
let workdir: string;

if (request.role === 'triage') {
// Triage agents are pure reasoning — no code changes, no git needed.
if (request.role === 'triage' || request.lightweight) {
// Triage/lightweight agents are pure reasoning — no code changes, no git needed.
// Use a lightweight workspace to avoid clone failures feeding the loop.
workdir = await createLightweightWorkspace('triage', request.rigId);
} else if (request.role === 'mayor') {
Expand Down
2 changes: 2 additions & 0 deletions cloudflare-gastown/container/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export const StartAgentRequest = z.object({
platformIntegrationId: z.string().optional(),
/** Git ref to branch from (e.g. convoy feature branch). Falls back to HEAD if absent. */
startPoint: z.string().optional(),
/** Skip repo clone — use a lightweight git-init-only workspace (for reasoning-only agents like triage). */
lightweight: z.boolean().optional(),
/** Rig list for mayor agents — used to set up browse worktrees on fresh containers. */
rigs: z
.array(
Expand Down
5 changes: 2 additions & 3 deletions cloudflare-gastown/src/dos/Town.do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2640,9 +2640,7 @@ export class TownDO extends DurableObject<Env> {
userId: rigConfig.userId,
agentId: triageAgent.id,
agentName: triageAgent.name,
// Use 'triage' role so the container skips the git clone entirely.
// Triage work is purely reasoning — no code changes needed.
role: 'triage',
role: 'polecat',
Comment thread
jrf0110 marked this conversation as resolved.
identity: triageAgent.identity,
beadId: triageBead.bead_id,
beadTitle: triageBead.title,
Expand All @@ -2654,6 +2652,7 @@ export class TownDO extends DurableObject<Env> {
townConfig,
systemPromptOverride: systemPrompt,
platformIntegrationId: rigConfig.platformIntegrationId,
lightweight: true,
});

if (started) {
Expand Down
33 changes: 5 additions & 28 deletions cloudflare-gastown/src/dos/town/beads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,18 +671,11 @@ export function getConvoyDependencyEdges(
}

/**
* Find the convoy a bead belongs to (if any).
*
* Two cases:
* 1. Normal source bead: tracked by a convoy via bead_dependencies
* (bead_id = sourceBeadId, depends_on_bead_id = convoyId, type = 'tracks').
* Returns the convoy bead_id.
* 2. The bead IS the convoy (e.g. for the final landing MR where processConvoyLandings
* passes the convoy bead_id as the source). Returns beadId itself.
* Find the convoy a bead belongs to (if any) via 'tracks' dependencies.
* Returns the convoy bead_id or null.
*/
export function getConvoyForBead(sql: SqlStorage, beadId: string): string | null {
// Case 1: bead is tracked by a convoy
const trackRows = [
const rows = [
...query(
sql,
/* sql */ `
Expand All @@ -694,24 +687,8 @@ export function getConvoyForBead(sql: SqlStorage, beadId: string): string | null
[beadId]
),
];
if (trackRows.length > 0) {
return z.object({ depends_on_bead_id: z.string() }).parse(trackRows[0]).depends_on_bead_id;
}

// Case 2: bead is itself a convoy (has convoy_metadata)
const metaRows = [
...query(
sql,
/* sql */ `
SELECT 1 FROM ${convoy_metadata}
WHERE ${convoy_metadata.bead_id} = ?
`,
[beadId]
),
];
if (metaRows.length > 0) return beadId;

return null;
if (rows.length === 0) return null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Convoy self-lookups now drop landing MR context

processConvoyLandings() submits the final landing review with bead_id = convoyId. Before this refactor getConvoyForBead() handled that case by returning beadId, but the new early return null treats convoy beads as standalone work. Landing review creation/review and any other callers that pass a convoy bead directly now lose convoy-specific routing and metadata.

return z.object({ depends_on_bead_id: z.string() }).parse(rows[0]).depends_on_bead_id;
}

/**
Expand Down
3 changes: 3 additions & 0 deletions cloudflare-gastown/src/dos/town/container-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ export async function startAgentInContainer(
platformIntegrationId?: string;
/** For convoy beads: the convoy's feature branch to branch from instead of defaultBranch. */
convoyFeatureBranch?: string;
/** Skip repo clone — use a lightweight workspace (for reasoning-only agents like triage). */
lightweight?: boolean;
/** All rigs in the town (mayor only) — used to set up browse worktrees on fresh containers. */
rigs?: Array<{
rigId: string;
Expand Down Expand Up @@ -346,6 +348,7 @@ export async function startAgentInContainer(
// For convoy agents, start from the convoy's feature branch so the
// worktree includes all previously merged convoy work.
startPoint: params.convoyFeatureBranch ? `origin/${params.convoyFeatureBranch}` : undefined,
lightweight: params.lightweight,
rigs: params.rigs,
}),
});
Expand Down
11 changes: 4 additions & 7 deletions cloudflare-gastown/src/dos/town/review-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,13 +277,10 @@ export function completeReviewWithResult(
const mergeTimestamp = now();
closeBead(sql, entry.bead_id, entry.agent_id);
Comment thread
jrf0110 marked this conversation as resolved.

// Explicitly trigger convoy progress for the source bead after the MR closes.
// closeBead → updateBeadStatus → updateConvoyProgress, but only if the source
// bead's status actually changes. If the polecat already closed the source bead
// before submitting to the review queue, the guard in updateBeadStatus short-
// circuits and updateConvoyProgress is never called. Calling it here directly
// ensures the convoy recounts after the MR bead is now closed (not in-flight),
// so the source bead passes the NOT EXISTS guard and counts toward closedCount.
// closeBead → updateBeadStatus short-circuits when completeReview already
// set the status to 'closed' via direct SQL, so updateConvoyProgress is
// never reached transitively. Call it explicitly to ensure the convoy
// recounts after the MR bead is closed.
updateConvoyProgress(sql, entry.bead_id, mergeTimestamp);

// If this was a convoy landing MR, also set landed_at on the convoy metadata
Expand Down
12 changes: 12 additions & 0 deletions cloudflare-gastown/src/trpc/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,15 @@ export const gastownProcedure = procedure.use(async ({ ctx, next }) => {
}
return next({ ctx });
});

/**
* Admin-only procedure — requires `isAdmin` on the JWT. Used for admin
* panel endpoints that bypass per-user ownership checks (e.g. town-wide
* bead/agent listing for support diagnostics).
*/
export const adminProcedure = procedure.use(async ({ ctx, next }) => {
if (!ctx.isAdmin) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin access required' });
}
return next({ ctx });
});
100 changes: 99 additions & 1 deletion cloudflare-gastown/src/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
/* eslint-disable @typescript-eslint/await-thenable -- DO RPC stubs return Rpc.Promisified which is thenable at runtime */
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { router, gastownProcedure } from './init';
import { router, gastownProcedure, adminProcedure } from './init';
import { getTownDOStub } from '../dos/Town.do';
import { getTownContainerStub } from '../dos/TownContainer.do';
import { getGastownUserStub } from '../dos/GastownUser.do';
Expand Down Expand Up @@ -598,6 +598,104 @@ export const gastownRouter = router({
const status = await townStub.getConvoyStatus(input.convoyId);
return status ?? { ...convoy, beads: [] };
}),

// ── Admin-only routes (bypass ownership checks) ──────────────────────

adminListBeads: adminProcedure
.input(
z.object({
townId: z.string().uuid(),
status: z.enum(['open', 'in_progress', 'closed', 'failed']).optional(),
type: z
.enum(['issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent'])
.optional(),
limit: z.number().int().positive().max(500).default(200),
})
)
.output(z.array(RpcBeadOutput))
.query(async ({ ctx, input }) => {
const townStub = getTownDOStub(ctx.env, input.townId);
return townStub.listBeads({
status: input.status,
type: input.type,
limit: input.limit,
});
}),

adminListAgents: adminProcedure
.input(z.object({ townId: z.string().uuid() }))
.output(z.array(RpcAgentOutput))
.query(async ({ ctx, input }) => {
const townStub = getTownDOStub(ctx.env, input.townId);
return townStub.listAgents({});
}),

adminForceRestartContainer: adminProcedure
.input(z.object({ townId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const containerStub = getTownContainerStub(ctx.env, input.townId);
await containerStub.destroy();
}),

adminForceResetAgent: adminProcedure
.input(z.object({ townId: z.string().uuid(), agentId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const townStub = getTownDOStub(ctx.env, input.townId);
await townStub.unhookBead(input.agentId);
await townStub.updateAgentStatus(input.agentId, 'idle');
}),

adminForceCloseBead: adminProcedure
.input(z.object({ townId: z.string().uuid(), beadId: z.string().uuid() }))
.output(RpcBeadOutput)
.mutation(async ({ ctx, input }) => {
const townStub = getTownDOStub(ctx.env, input.townId);
return townStub.closeBead(input.beadId, 'admin');
}),

adminForceFailBead: adminProcedure
.input(z.object({ townId: z.string().uuid(), beadId: z.string().uuid() }))
.output(RpcBeadOutput)
.mutation(async ({ ctx, input }) => {
const townStub = getTownDOStub(ctx.env, input.townId);
return townStub.updateBeadStatus(input.beadId, 'failed', 'admin');
}),

adminGetAlarmStatus: adminProcedure
.input(z.object({ townId: z.string().uuid() }))
.output(RpcAlarmStatusOutput)
.query(async ({ ctx, input }) => {
const townStub = getTownDOStub(ctx.env, input.townId);
await townStub.setTownId(input.townId);
return townStub.getAlarmStatus();
}),

adminGetTownEvents: adminProcedure
.input(
z.object({
townId: z.string().uuid(),
beadId: z.string().uuid().optional(),
since: z.string().optional(),
limit: z.number().int().positive().max(500).default(100),
})
)
.output(z.array(RpcBeadEventOutput))
.query(async ({ ctx, input }) => {
const townStub = getTownDOStub(ctx.env, input.townId);
return townStub.listBeadEvents({
beadId: input.beadId,
since: input.since,
limit: input.limit,
});
}),

adminGetBead: adminProcedure
.input(z.object({ townId: z.string().uuid(), beadId: z.string().uuid() }))
.output(RpcBeadOutput.nullable())
.query(async ({ ctx, input }) => {
const townStub = getTownDOStub(ctx.env, input.townId);
return townStub.getBeadAsync(input.beadId);
}),
});

export type GastownRouter = typeof gastownRouter;
Expand Down
6 changes: 6 additions & 0 deletions src/app/admin/components/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
Upload,
Bell,
Server,
Network,
} from 'lucide-react';
import { useSession } from 'next-auth/react';
import type { Session } from 'next-auth';
Expand Down Expand Up @@ -144,6 +145,11 @@ const productEngineeringItems: MenuItem[] = [
url: '/admin/code-indexing',
icon: () => <Database />,
},
{
title: () => 'Gastown',
url: '/admin/gastown',
icon: () => <Network />,
},
];

const analyticsObservabilityItems: MenuItem[] = [
Expand Down
3 changes: 3 additions & 0 deletions src/app/admin/components/UserAdmin/UserAdminDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from '@/components/ui/breadcrumb';
import { UserAdminOrganizations } from '@/app/admin/components/UserAdmin/UserAdminOrganizations';
import { UserAdminKiloPass } from '@/app/admin/components/UserAdmin/UserAdminKiloPass';
import { UserAdminGastown } from '@/app/admin/components/UserAdmin/UserAdminGastown';

export function UserAdminDashboard({ ...user }: UserDetailProps) {
const breadcrumbs = (
Expand Down Expand Up @@ -59,6 +60,8 @@ export function UserAdminDashboard({ ...user }: UserDetailProps) {
<UserAdminInvoices stripe_customer_id={user.stripe_customer_id} />
<UserAdminReferrals kilo_user_id={user.id} />
</div>

<UserAdminGastown userId={user.id} />
</div>
</AdminPage>
);
Expand Down
Loading
Loading