From 39bb03e30b9d9762cbc71d38154cde2f3be5e6ab Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 21:29:38 +0100 Subject: [PATCH] fix: Reconcile orphaned changesets when phases are manually deleted Manually deleting phases left their parent changeset as "applied", causing the Plan tab to show a stale "Created N phases" banner with no phases visible. - deletePhase now checks if all phases from a changeset are gone and marks it reverted - PlanSection filters out dismissed agents so dismissed banners stay hidden - revertChangeSet marks reverted before entity deletion to prevent ghost state on partial failure - deletePhase invalidation now includes listChangeSets --- .../db/repositories/change-set-repository.ts | 6 ++++ .../db/repositories/drizzle/change-set.ts | 28 ++++++++++++++++++- apps/server/trpc/routers/change-set.ts | 5 ++-- apps/server/trpc/routers/phase.ts | 25 ++++++++++++++++- .../src/components/execution/PlanSection.tsx | 1 + apps/web/src/lib/invalidation.ts | 2 +- 6 files changed, 62 insertions(+), 5 deletions(-) diff --git a/apps/server/db/repositories/change-set-repository.ts b/apps/server/db/repositories/change-set-repository.ts index 17f0164..23b09a5 100644 --- a/apps/server/db/repositories/change-set-repository.ts +++ b/apps/server/db/repositories/change-set-repository.ts @@ -33,4 +33,10 @@ export interface ChangeSetRepository { findByInitiativeId(initiativeId: string): Promise; findByAgentId(agentId: string): Promise; markReverted(id: string): Promise; + + /** + * Find applied changesets that have a 'create' entry for the given entity. + * Used to reconcile changeset status when entities are manually deleted. + */ + findAppliedByCreatedEntity(entityType: string, entityId: string): Promise; } diff --git a/apps/server/db/repositories/drizzle/change-set.ts b/apps/server/db/repositories/drizzle/change-set.ts index 0fc871a..19b8714 100644 --- a/apps/server/db/repositories/drizzle/change-set.ts +++ b/apps/server/db/repositories/drizzle/change-set.ts @@ -4,7 +4,7 @@ * Implements ChangeSetRepository interface using Drizzle ORM. */ -import { eq, desc, asc } from 'drizzle-orm'; +import { eq, desc, asc, and } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import type { DrizzleDatabase } from '../../index.js'; import { changeSets, changeSetEntries, type ChangeSet } from '../../schema.js'; @@ -94,6 +94,32 @@ export class DrizzleChangeSetRepository implements ChangeSetRepository { .orderBy(desc(changeSets.createdAt)); } + async findAppliedByCreatedEntity(entityType: string, entityId: string): Promise { + // Find changeset entries matching the entity + const matchingEntries = await this.db + .select({ changeSetId: changeSetEntries.changeSetId }) + .from(changeSetEntries) + .where( + and( + eq(changeSetEntries.entityType, entityType as any), + eq(changeSetEntries.entityId, entityId), + eq(changeSetEntries.action, 'create'), + ), + ); + + const results: ChangeSetWithEntries[] = []; + const seen = new Set(); + for (const { changeSetId } of matchingEntries) { + if (seen.has(changeSetId)) continue; + seen.add(changeSetId); + const cs = await this.findByIdWithEntries(changeSetId); + if (cs && cs.status === 'applied') { + results.push(cs); + } + } + return results; + } + async markReverted(id: string): Promise { const [updated] = await this.db .update(changeSets) diff --git a/apps/server/trpc/routers/change-set.ts b/apps/server/trpc/routers/change-set.ts index 344c7f3..111bf54 100644 --- a/apps/server/trpc/routers/change-set.ts +++ b/apps/server/trpc/routers/change-set.ts @@ -91,6 +91,9 @@ export function changeSetProcedures(publicProcedure: ProcedureBuilder) { } } + // Mark reverted FIRST to avoid ghost state if entity deletion fails partway + await repo.markReverted(input.id); + // Apply reverts in reverse entry order const reversedEntries = [...cs.entries].reverse(); for (const entry of reversedEntries) { @@ -159,8 +162,6 @@ export function changeSetProcedures(publicProcedure: ProcedureBuilder) { } } - await repo.markReverted(input.id); - ctx.eventBus.emit({ type: 'changeset:reverted' as const, timestamp: new Date(), diff --git a/apps/server/trpc/routers/phase.ts b/apps/server/trpc/routers/phase.ts index ba1e138..604b45a 100644 --- a/apps/server/trpc/routers/phase.ts +++ b/apps/server/trpc/routers/phase.ts @@ -6,7 +6,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import type { Phase } from '../../db/schema.js'; import type { ProcedureBuilder } from '../trpc.js'; -import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator, requireReviewCommentRepository } from './_helpers.js'; +import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator, requireReviewCommentRepository, requireChangeSetRepository } from './_helpers.js'; import { phaseBranchName } from '../../git/branch-naming.js'; import { ensureProjectClone } from '../../git/project-clones.js'; @@ -98,6 +98,29 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) { .mutation(async ({ ctx, input }) => { const repo = requirePhaseRepository(ctx); await repo.delete(input.id); + + // Reconcile any applied changesets that created this phase. + // If all created phases in a changeset are now deleted, mark it reverted. + if (ctx.changeSetRepository) { + try { + const csRepo = requireChangeSetRepository(ctx); + const affectedChangeSets = await csRepo.findAppliedByCreatedEntity('phase', input.id); + for (const cs of affectedChangeSets) { + const createdPhaseIds = cs.entries + .filter(e => e.entityType === 'phase' && e.action === 'create') + .map(e => e.entityId); + const survivingPhases = await Promise.all( + createdPhaseIds.map(id => repo.findById(id)), + ); + if (survivingPhases.every(p => p === null)) { + await csRepo.markReverted(cs.id); + } + } + } catch { + // Best-effort reconciliation — don't fail the delete + } + } + return { success: true }; }), diff --git a/apps/web/src/components/execution/PlanSection.tsx b/apps/web/src/components/execution/PlanSection.tsx index a79e3dc..fc24af4 100644 --- a/apps/web/src/components/execution/PlanSection.tsx +++ b/apps/web/src/components/execution/PlanSection.tsx @@ -27,6 +27,7 @@ export function PlanSection({ (a) => a.mode === "plan" && a.initiativeId === initiativeId && + !a.userDismissedAt && ["running", "waiting_for_input", "idle"].includes(a.status), ) .sort( diff --git a/apps/web/src/lib/invalidation.ts b/apps/web/src/lib/invalidation.ts index f08105d..e318aef 100644 --- a/apps/web/src/lib/invalidation.ts +++ b/apps/web/src/lib/invalidation.ts @@ -52,7 +52,7 @@ const INVALIDATION_MAP: Partial> = { // --- Phases --- createPhase: ["listPhases", "listInitiativePhaseDependencies"], - deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies"], + deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies", "listChangeSets"], updatePhase: ["listPhases", "getPhase"], approvePhase: ["listPhases", "listInitiativeTasks"], queuePhase: ["listPhases"],