Skip to content

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

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.

Activity

  1. coygeek commented on May 30, 2026

    @coygeek
    Author

    Version: 0.0.25-nightly.20260530.413 (b3e8c03)

  2. added a commit that references this issue on Jun 16, 2026
    ff560d9
  3. coygeek commented on Jun 27, 2026

    @coygeek
    Author

    Addendum for #2866

    I hit this same project deletion bug on T3 Code Alpha 0.0.27.

    The UI showed a project group with No threads yet, so the remove action appeared to be a simple project-entry removal. When I tried to remove the project, the app showed this backend invariant error instead:

    Project '<project-id>' is not empty and cannot be deleted without force=true.
    

    After inspecting the local state, the project had archived threads only:

    • visible sidebar thread count: 0
    • non-deleted archived thread count: 2
    • project deleted_at: null

    That matches the diagnosis in this issue: the sidebar/shell snapshot treats archived threads as absent for display, but project.delete treats any non-deleted thread, including archived threads, as making the project non-empty. The result is a project that looks empty to the user but cannot be removed through the normal empty-project removal path.

    I saw this with two separate local projects. Each had only archived threads, and both failed the same way until the equivalent of a forced project delete was applied. After marking the archived threads deleted and then marking the project deleted, both entries disappeared from the sidebar as expected.

    No sensitive local paths or project names are needed to reproduce this. The relevant shape is:

    1. Create a project.
    2. Create one or more threads.
    3. Archive every thread in the project.
    4. Restart/reload so the sidebar shell snapshot shows the project with no visible threads.
    5. Remove the project.
    6. Observe the cannot be deleted without force=true error instead of a successful removal or a force-delete confirmation.

    Current source check from main at 6245c54 still appears to have the same mismatch:

    • apps/web/src/components/Sidebar.tsx chooses the non-force path when memberThreadCountByPhysicalKey is 0.
    • apps/server/src/orchestration/decider.ts rejects project.delete unless all threads for the project have deletedAt !== null, regardless of archivedAt.

    Suggested fix direction: make the deletion prompt/count use the same "non-empty" definition as the server, or make the server's deletion invariant match the shell snapshot's visible-thread definition. From the user's point of view, the important part is that a project showing No threads yet should not dead-end into an invariant error.

  4. erykwieliczko commented on Jul 4, 2026

    @erykwieliczko

    Still happens in 0.0.28.

  5. tommynordli commented on Aug 3, 2026

    @tommynordli

    Still hitting this on 0.0.31.

    Sidebar said "No threads yet" so I figured it'd just remove the project entry, but delete failed with Project '…' is not empty and cannot be deleted without force=true. Checked local state and there was one archived thread hanging around — archived_at set, deleted_at null. So the sidebar sees 0 threads and takes the non-force path, but the server still counts the archived one and blocks it.

    Got past it by marking the archived thread deleted in state.sqlite, then the normal remove worked fine.

  6. martin-knopf commented on Aug 21, 2026

    @martin-knopf

    Opus fixed it for me via ELECTRON_RUN_AS_NODE=1 "/Applications/T3 Code (Alpha).app/Contents/MacOS/T3 Code (Alpha)" ".../app.asar/apps/server/dist/bin.mjs" project remove 0307e251-... --force.

  7. t3dotgg commented on Aug 27, 2026

    @t3dotgg
    Member

    Related report #4632 covers the same problem and adds useful evidence.

    The reporter archived the project's only thread, then removal failed with the exact force=true invariant. Issue 2866 already tracks that same archived-only project, empty sidebar count, and unforced delete mismatch. Its comments include the same stable-version failure and hidden archived row. This is not a distinct zero-thread condition.

    I'm linking the report here before I close it as a duplicate during an automated pass. The source report and its attachments will remain available at #4632.

  8. andybergon commented on Aug 30, 2026

    @andybergon

    Still reproduces on 0.0.36 (stable, Windows) — the thread's confirmations previously topped out at 0.0.31. Same shape: sidebar showed the project as empty, Remove failed with Project '<id>' is not empty and cannot be deleted without force=true, and the one blocking thread had archived_at set / deleted_at null.

    A cleaner workaround than editing state.sqlite directly, since it goes through the normal event store: dispatch the forced delete to the running local server instead of hand-writing the DB. POST /api/orchestration/dispatch with body {"type":"project.delete","commandId":"<uuid>","projectId":"<id>","force":true} and an Authorization: Bearer <session-token> header (needs the orchestration:operate scope). The server then cascades a thread.delete for each archived thread followed by project.delete, exactly as the --force CLI path does, so projections stay consistent with no manual DB surgery.

    The underlying mismatch is unchanged: the delete dialog's empty-check counts only non-archived threads while the server invariant blocks on any deletedAt === null thread. A confirm-and-force option in the Remove dialog (or aligning the two "empty" definitions) would close it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions