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
29 changes: 27 additions & 2 deletions cloudflare-gastown/src/dos/Town.do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2561,6 +2561,17 @@ export class TownDO extends DurableObject<Env> {
// ══════════════════════════════════════════════════════════════════

async alarm(): Promise<void> {
// Exit condition: if this DO was destroyed, don't re-arm.
// After destroy(), deleteAll() wipes storage but may not clear
// the alarm (compat date < 2026-02-24). A resurrected alarm
// will find no town:id — stop the loop immediately.
const storedId = await this.ctx.storage.get<string>('town:id');
if (!storedId) {
console.log(`${TOWN_LOG} alarm: no town:id — town was destroyed, not re-arming`);
await this.ctx.storage.deleteAlarm();
return;
}

await this.ensureInitialized();
const townId = this.townId;
console.log(`${TOWN_LOG} alarm: fired for town=${townId}`);
Expand Down Expand Up @@ -3801,6 +3812,11 @@ export class TownDO extends DurableObject<Env> {
// ── Alarm helpers ─────────────────────────────────────────────────

private async armAlarmIfNeeded(): Promise<void> {
// Don't resurrect the alarm on a destroyed DO. After destroy(),
// town:id is wiped — if it's missing, the town was deleted.
const storedId = await this.ctx.storage.get<string>('town:id');
if (!storedId) return;
Comment thread
jrf0110 marked this conversation as resolved.

const current = await this.ctx.storage.getAlarm();
if (!current || current < Date.now()) {
await this.ctx.storage.setAlarm(Date.now() + ACTIVE_ALARM_INTERVAL_MS);
Expand Down Expand Up @@ -4044,13 +4060,22 @@ export class TownDO extends DurableObject<Env> {
async destroy(): Promise<void> {
console.log(`${TOWN_LOG} destroy: clearing all storage and alarms`);

// Destroy all AgentDOs (clears agent_events tables)
try {
const allAgents = agents.listAgents(this.sql);
await Promise.allSettled(
Comment thread
jrf0110 marked this conversation as resolved.
allAgents.map(agent => getAgentDOStub(this.env, agent.id).destroy())
);
} catch {
// Best-effort
} catch (err) {
console.warn(`${TOWN_LOG} destroy: agent cleanup failed`, err);
}

// Destroy TownContainerDO (sends SIGKILL to container process, clears state)
try {
const containerStub = getTownContainerStub(this.env, this.townId);
await containerStub.destroy();
} catch (err) {
console.warn(`${TOWN_LOG} destroy: container cleanup failed`, err);
}

await this.ctx.storage.deleteAlarm();
Expand Down
8 changes: 8 additions & 0 deletions cloudflare-gastown/src/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ export const gastownRouter = router({
.input(z.object({ townId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
await verifyTownOwnership(ctx.env, ctx.userId, input.townId);

// Destroy the Town DO (agents, container, alarms, storage).
// Let failures propagate — if cleanup fails, don't delete the
// user record (that's the only reference for recovering the
// leaked resources).
const townDOStub = getTownDOStub(ctx.env, input.townId);
await townDOStub.destroy();

const userStub = getGastownUserStub(ctx.env, ctx.userId);
await userStub.deleteTown(input.townId);
}),
Expand Down
212 changes: 212 additions & 0 deletions cloudflare-gastown/test/integration/town-deletion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { env, runDurableObjectAlarm } from 'cloudflare:test';
import { describe, it, expect, beforeEach } from 'vitest';

function getTownStub(name: string) {
return env.TOWN.get(env.TOWN.idFromName(name));
}

function getUserStub(name: string) {
return env.GASTOWN_USER.get(env.GASTOWN_USER.idFromName(name));
}

function getAgentStub(name: string) {
return env.AGENT.get(env.AGENT.idFromName(name));
}

describe('Town deletion (#1182)', () => {
let townName: string;
let town: ReturnType<typeof getTownStub>;

beforeEach(() => {
townName = `town-del-${crypto.randomUUID()}`;
town = getTownStub(townName);
});

// ── TownDO.destroy() ──────────────────────────────────────────────────

describe('TownDO.destroy()', () => {
it('should clear all storage so beads are no longer retrievable', async () => {
await town.createBead({ type: 'issue', title: 'Doomed bead' });
await town.registerAgent({
role: 'polecat',
name: 'P1',
identity: `del-agent-${townName}`,
});

await town.destroy();

// After destroy, the same stub should find no data (storage was cleared)
const beads = await town.listBeads({});
expect(beads).toHaveLength(0);
});

it('should clear all agents from storage', async () => {
await town.registerAgent({
role: 'polecat',
name: 'P1',
identity: `del-agents-a-${townName}`,
});
await town.registerAgent({
role: 'refinery',
name: 'R1',
identity: `del-agents-b-${townName}`,
});

const agentsBefore = await town.listAgents();
expect(agentsBefore).toHaveLength(2);

await town.destroy();

const agentsAfter = await town.listAgents();
expect(agentsAfter).toHaveLength(0);
});

it('should delete the alarm so it does not re-fire', async () => {
// setTownId is required for armAlarmIfNeeded to arm the alarm
await town.setTownId(townName);
await town.slingBead({ type: 'issue', title: 'Alarm bead', rigId: 'test-rig' });
Comment thread
jrf0110 marked this conversation as resolved.
const ranBefore = await runDurableObjectAlarm(town);
expect(ranBefore).toBe(true);

await town.destroy();

// After destroy, the alarm should not re-fire
const ranAfter = await runDurableObjectAlarm(town);
expect(ranAfter).toBe(false);
});

it('should destroy AgentDOs (clearing their event tables)', async () => {
const agent = await town.registerAgent({
role: 'polecat',
name: 'P1',
identity: `del-agentdo-${townName}`,
});

// Write events to the AgentDO
const agentDO = getAgentStub(agent.id);
await agentDO.appendEvents([{ type: 'session.start', data: JSON.stringify({ test: true }) }]);

const eventsBefore = await agentDO.getEvents();
expect(eventsBefore.length).toBeGreaterThan(0);

await town.destroy();

// AgentDO should have been destroyed — events cleared
const eventsAfter = await agentDO.getEvents();
expect(eventsAfter).toHaveLength(0);
});
});

// ── Alarm exit condition ────────────────────────────────────────────────

describe('alarm exit condition', () => {
it('should not re-arm alarm on a destroyed DO', async () => {
// setTownId is required for armAlarmIfNeeded to arm the alarm
await town.setTownId(townName);
await town.configureRig({
rigId: 'test-rig',
townId: townName,
gitUrl: 'https://github.com/org/repo.git',
defaultBranch: 'main',
userId: 'test-user',
});

// Arm alarm
await town.slingBead({ type: 'issue', title: 'Active bead', rigId: 'test-rig' });
const ranBefore = await runDurableObjectAlarm(town);
expect(ranBefore).toBe(true);

await town.destroy();

// deleteAll() with compat date >= 2026-02-24 clears alarms,
// so this should return false
const ranAfterDestroy = await runDurableObjectAlarm(town);
expect(ranAfterDestroy).toBe(false);
});

it('should not resurrect alarm when accessing a destroyed town', async () => {
await town.createBead({ type: 'issue', title: 'Soon to die' });

await town.destroy();

// Accessing the destroyed DO triggers ensureInitialized() →
// armAlarmIfNeeded(), which should NOT re-arm because town:id is gone
const beads = await town.listBeads({});
expect(beads).toHaveLength(0);

// Alarm should NOT have been re-armed
const ran = await runDurableObjectAlarm(town);
expect(ran).toBe(false);
});
});

// ── GastownUserDO.deleteTown() ─────────────────────────────────────────

describe('GastownUserDO.deleteTown()', () => {
it('should remove the town from the user list', async () => {
const userId = `user-${crypto.randomUUID()}`;
const userStub = getUserStub(userId);

// createTown generates its own id; it requires name + owner_user_id
const created = await userStub.createTown({
name: 'Test Town',
owner_user_id: userId,
});

const townsBefore = await userStub.listTowns();
expect(townsBefore).toHaveLength(1);

const deleted = await userStub.deleteTown(created.id);
expect(deleted).toBe(true);

const townsAfter = await userStub.listTowns();
expect(townsAfter).toHaveLength(0);
});
});

// ── Full deletion flow (tRPC-equivalent) ─────────────────────────────────

describe('full deletion flow', () => {
it('should clean up TownDO storage and user records', async () => {
const userId = `user-${crypto.randomUUID()}`;
const userStub = getUserStub(userId);

// createTown generates its own id; it requires name + owner_user_id
const created = await userStub.createTown({
name: 'Full Delete Town',
owner_user_id: userId,
});
const townId = created.id;

// Set up TownDO with data (setTownId mirrors the tRPC createTown flow)
const townStub = getTownStub(townId);
await townStub.setTownId(townId);
await townStub.createBead({ type: 'issue', title: 'Bead in deleted town' });
await townStub.registerAgent({
role: 'polecat',
name: 'P1',
identity: `full-del-${townId}`,
});

// Simulate the fixed tRPC deleteTown flow:
// 1. Destroy TownDO (agents, container, alarms, storage)
await townStub.destroy();
// 2. Remove from user's list
await userStub.deleteTown(townId);

// Verify user-side cleanup
const towns = await userStub.listTowns();
expect(towns).toHaveLength(0);

// Verify TownDO-side cleanup
const beads = await townStub.listBeads({});
expect(beads).toHaveLength(0);
const agents = await townStub.listAgents();
expect(agents).toHaveLength(0);

// Verify alarm is dead
const ran = await runDurableObjectAlarm(townStub);
expect(ran).toBe(false);
});
});
});
2 changes: 1 addition & 1 deletion cloudflare-gastown/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "node_modules/wrangler/config-schema.json",
"name": "gastown",
"main": "src/gastown.worker.ts",
"compatibility_date": "2026-01-27",
"compatibility_date": "2026-02-24",
"compatibility_flags": ["nodejs_compat"],
"placement": { "mode": "smart" },
"observability": { "enabled": true },
Expand Down
2 changes: 1 addition & 1 deletion cloudflare-gastown/wrangler.test.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// Test configuration - stripped down for vitest-pool-workers
"name": "gastown-test",
"main": "src/gastown.worker.ts",
"compatibility_date": "2026-01-27",
"compatibility_date": "2026-02-24",
"compatibility_flags": ["nodejs_compat", "service_binding_extra_handlers"],

"durable_objects": {
Expand Down
Loading
Loading