Repository navigation
Bug: Cannot delete project with only archived threads — client/server state mismatch #2866
Description
Activity
Version: 0.0.25-nightly.20260530.413 (b3e8c03)
- added a commit that references this issue
on Jun 16, 2026 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.deletetreats 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:
- Create a project.
- Create one or more threads.
- Archive every thread in the project.
- Restart/reload so the sidebar shell snapshot shows the project with no visible threads.
- Remove the project.
- Observe the
cannot be deleted without force=trueerror instead of a successful removal or a force-delete confirmation.
Current source check from
mainat6245c54still appears to have the same mismatch:apps/web/src/components/Sidebar.tsxchooses the non-force path whenmemberThreadCountByPhysicalKeyis0.apps/server/src/orchestration/decider.tsrejectsproject.deleteunless all threads for the project havedeletedAt !== null, regardless ofarchivedAt.
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 yetshould not dead-end into an invariant error.- visible sidebar thread count:
Still happens in
0.0.28.Reacted by Mike de GeofroyStill 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_atset,deleted_atnull. 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.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.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.
Still reproduces on
0.0.36(stable, Windows) — the thread's confirmations previously topped out at0.0.31. Same shape: sidebar showed the project as empty, Remove failed withProject '<id>' is not empty and cannot be deleted without force=true, and the one blocking thread hadarchived_atset /deleted_atnull.A cleaner workaround than editing
state.sqlitedirectly, 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/dispatchwith body{"type":"project.delete","commandId":"<uuid>","projectId":"<id>","force":true}and anAuthorization: Bearer <session-token>header (needs theorchestration:operatescope). The server then cascades athread.deletefor each archived thread followed byproject.delete, exactly as the--forceCLI 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 === nullthread. A confirm-and-force option in the Remove dialog (or aligning the two "empty" definitions) would close it.Reacted by Qbisz
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.deletewithoutforce=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
Project '...' is not empty and cannot be deleted without force=true.Expected Behavior
Either:
project.deletewithforce=true.Actual Behavior
The client thinks the project is empty (0 visible threads) and sends a plain
project.deletecommand. The server sees archived threads withdeletedAt === nulland rejects the command.Root Cause Analysis
Server-side logic
In
apps/server/src/orchestration/decider.ts(lines 169-172):The server considers any thread with
deletedAt === nullas 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 querylistActiveThreadRowsfilters with:This means archived threads are excluded from the shell snapshot sent to the client.
In
apps/web/src/store.ts(lines 1084-1122),syncEnvironmentShellSnapshotcompletely rebuilds the client'sthreadIdsByProjectIdfrom the snapshot:After a snapshot reload, archived threads disappear from the client's view.
In
apps/web/src/components/Sidebar.tsx(lines 1085-1099),memberThreadCountByPhysicalKeyis derived fromprojectThreads, which comes fromselectSidebarThreadsForProjectRefs. Since archived threads are no longer inthreadIdsByProjectId, the count is 0.In
apps/web/src/components/Sidebar.tsx(lines 1315-1371),handleRemoveProjectchecks:Because
memberThreadCountis 0, the client skips the force-delete path and sendsproject.deletewithoutforce=true, which the server rejects.Inconsistent state sources
The client has two sources of truth for thread membership:
thread.archivedis received, the reducer (apps/web/src/store.ts:1293-1298) updatesarchivedAtbut does not remove the thread fromthreadIdsByProjectId.threadIdsByProjectIdis 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
Project is not empty and cannot be deleted without force=true.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:memberThreadCountByPhysicalKeywhen checking for project deletion eligibility, OROption B (Server fix)
In
apps/server/src/orchestration/decider.ts, change the "active threads" check to exclude archived threads: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
threadIdsByProjectIdbut mark them witharchivedAt. The client already knows how to filter archived threads for display; it just needs to know they exist for deletion checks.Environment
Additional Notes
decider.delete.test.tsdoes not include a test case for deleting a project with archived threads.ProjectionSnapshotQuerytests do not verify that archived threads are excluded from the shell snapshot while still being considered "active" by the decider.