Skip to content

Bug: Cannot delete project with only archived threads — client/server state mismatch #2866

@coygeek

Description

@coygeek

Bug Report: Deleting project with archived threads fails with "cannot be deleted without force=true"

Summary

When attempting to delete a project that contains only archived threads, the UI shows "No threads yet" and sends project.delete without force=true. The server rejects this because archived threads are still considered "active" (not deleted), causing a confusing invariant error. This is a client-server state synchronization bug caused by inconsistent definitions of what makes a project "empty."

Steps to Reproduce

  1. Create a project.
  2. Create one or more threads in that project.
  3. Archive all threads in the project.
  4. Wait for a shell snapshot reload (e.g. reconnect, restart app, or trigger rebootstrap).
  5. Right-click the project and select Remove project.
  6. The confirmation dialog shows "This removes only this project entry" (simple dialog, not the force-delete warning).
  7. Click Yes.
  8. Error toast appears: Project '...' is not empty and cannot be deleted without force=true.

Expected Behavior

Either:

  • Option A: The client should detect archived threads and show the force-delete confirmation dialog ("Delete anyway"), then send project.delete with force=true.
  • Option B: The server should consider a project with only archived threads as "empty" for deletion purposes, since archived threads are already hidden from the user.

Actual Behavior

The client thinks the project is empty (0 visible threads) and sends a plain project.delete command. The server sees archived threads with deletedAt === null and rejects the command.

Root Cause Analysis

Server-side logic

In apps/server/src/orchestration/decider.ts (lines 169-172):

const activeThreads = listThreadsByProjectId(readModel, command.projectId).filter(
  (thread) => thread.deletedAt === null,
);
if (activeThreads.length > 0 && command.force !== true) {
  return yield* new OrchestrationCommandInvariantError({
    commandType: command.type,
    detail: `Project '${command.projectId}' is not empty and cannot be deleted without force=true.`,
  });
}

The server considers any thread with deletedAt === null as making the project non-empty. Archived threads (archivedAt !== null) are included because they are not deleted.

Client-side logic

In apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts (lines 346-374), the shell snapshot query listActiveThreadRows filters with:

WHERE deleted_at IS NULL
  AND archived_at IS NULL

This means archived threads are excluded from the shell snapshot sent to the client.

In apps/web/src/store.ts (lines 1084-1122), syncEnvironmentShellSnapshot completely rebuilds the client's threadIdsByProjectId from the snapshot:

let nextState: EnvironmentState = {
  ...state,
  ...buildProjectState(nextProjects),
  threadIds: [],
  threadIdsByProjectId: {},
  // ...
};

After a snapshot reload, archived threads disappear from the client's view.

In apps/web/src/components/Sidebar.tsx (lines 1085-1099), memberThreadCountByPhysicalKey is derived from projectThreads, which comes from selectSidebarThreadsForProjectRefs. Since archived threads are no longer in threadIdsByProjectId, the count is 0.

In apps/web/src/components/Sidebar.tsx (lines 1315-1371), handleRemoveProject checks:

const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0;
if (memberThreadCount > 0) {
  // Show force-delete warning with "Delete anyway"
} else {
  // Show simple confirmation dialog, call removeProject(member) WITHOUT force=true
}

Because memberThreadCount is 0, the client skips the force-delete path and sends project.delete without force=true, which the server rejects.

Inconsistent state sources

The client has two sources of truth for thread membership:

  • Event-driven updates: When thread.archived is received, the reducer (apps/web/src/store.ts:1293-1298) updates archivedAt but does not remove the thread from threadIdsByProjectId.
  • Snapshot-driven resets: When a shell snapshot is received, threadIdsByProjectId is rebuilt from scratch, excluding archived threads.

This creates a synchronization gap: after a snapshot reload, the client and server have fundamentally different views of which threads belong to a project.

Evidence

  • Error toast: Project is not empty and cannot be deleted without force=true.
  • Confirmation dialog shows the simple "Remove project?" prompt instead of the force-delete warning with "Delete anyway".
  • The project sidebar displays "No threads yet" even though the server knows about threads.

Suggested Fix

Recommended approach: Align the client's thread counting with the server's deletion logic. The client should query the full thread list (including archived threads) when deciding whether to prompt for force deletion, or the server should exclude archived threads from the "non-empty" check.

Option A (Client fix, preferred for UX consistency)

In apps/web/src/components/Sidebar.tsx, when determining if a project is empty for deletion purposes, the client should consider threads that exist on the server even if they are archived. This could be done by:

  • Including archived threads in memberThreadCountByPhysicalKey when checking for project deletion eligibility, OR
  • Querying the server for the actual thread count before attempting deletion.

Option B (Server fix)

In apps/server/src/orchestration/decider.ts, change the "active threads" check to exclude archived threads:

const activeThreads = listThreadsByProjectId(readModel, command.projectId).filter(
  (thread) => thread.deletedAt === null && thread.archivedAt === null,
);

This would align the server's "empty project" definition with the client's visible state. However, this might have implications for other operations that expect archived threads to still block deletion.

Option C (Projection/Snapshot fix)

Include archived threads in the shell snapshot's threadIdsByProjectId but mark them with archivedAt. The client already knows how to filter archived threads for display; it just needs to know they exist for deletion checks.

Environment

  • App: T3 Code Nightly
  • OS: macOS

Additional Notes

  • The decider.delete.test.ts does not include a test case for deleting a project with archived threads.
  • The ProjectionSnapshotQuery tests do not verify that archived threads are excluded from the shell snapshot while still being considered "active" by the decider.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions