Thread-less project removal: real HTTP and SQLite checks
2026-09-30 · Bug · Low priority · Medium effort
NOT REPRODUCED in the tested server lifecycle. High confidence in these repeatable server results; the original intermittent UI failure remains unresolved.
Claim, expected and actual
The issue describes Remove/confirm doing nothing repeatedly for a newly added plain parent directory containing two existing project directories, with no threads or environments. Creating a thread preceded a later successful removal. The original report also records unsuccessful attempts to reproduce via CLI and loopback headless UI. Its affected browser session used an HTTPS reverse proxy.
Expected: a normal project can be removed without creating a thread. Actual in both clean runs: all 20 freshly created thread-less projects were removed through the real HTTP DELETE route, disappeared from the public database listing by the response, and were physically deleted by asynchronous cleanup. Two existing child-source projects and a source sentinel file survived each removal. Ten cases had no environments; ten added one unmanaged environment before deletion. The unmanaged environment rows were removed too.
| Case per clean run | Actual result |
|---|---|
| 20 fresh POST-create / DELETE cases | All create responses 201; all delete responses 200 with {ok:true}; parent hidden and physically removed; nested projects and source file preserved |
| Five concurrent DELETE requests for one fresh project | See exact statuses below; at least one 200, others 200 or 404 allowed; project physically removed |
| Idle thread with no environment control | HTTP 200; actual deletion advance returns true; both project and thread rows removed |
This is a faithful server-side subset of the reported removal conditions, not a simulation returning unconditional success. It uses the repository’s actual route, transaction, deletion services, asynchronous response deferral and migrated SQLite database. It does not establish that a browser sent the DELETE in the original incident or that a reverse proxy delivered it. The report is not an ALREADY FIXED finding and does not justify closing the issue.
Implementation findings and uncertainty
The real DELETE route looks up the project, rejects the personal project, begins deletion, schedules physical cleanup and returns success. The transaction marks the project deleted even when the thread list is empty. Physical cleanup waits for remaining thread rows and managed environment teardown, then deletes the project row and attachments. Neither tested empty-project branch requires creating a thread.
Environment-less thread storage cleanup marks storage deleted and finalizes the stopped thread directly. This explains the successful thread-bearing control, rather than demonstrating that a thread is necessary. Database deletion removes the selected project row. The fixture verifies that nested local source paths do not imply deleting other project rows or the source sentinel.
Root cause of the original intermittent incident: unknown. The tested main does not reproduce it. Proposed next test: record whether the actual Remove/confirm handler dispatches DELETE, its response and pending-mutation state, and the subsequent project-list update under the affected browser and reverse-proxy topology. Correlate those with server deletion state using synthetic projects. Add focused UI menu-to-dialog lifecycle coverage before choosing a production fix. Do not add an arbitrary thread-creation workaround based on this evidence. No production fix or PR was created.
Trusted base and environment
Public trusted origin/main: e52f3662fb6e52eb85238d22a07d1bac16b6e430. Linux x86_64, Node 24.19.0, repository-pinned pnpm 9.15.0, Vitest 4.1.1. Both clean detached checkouts used separate node_modules and installation caches. Dependency downloads were shared. Both frozen installs succeeded; the forced Turbo test graph completed all nine tasks without cached task results. Each case starts its own loopback HTTP listener on an OS-assigned port, fresh migrated in-memory SQLite database and owned temporary data directory. No real host connection, model call, editor, user project or file was used. This is not macOS/browser or reverse-proxy execution.
Exact repeatable steps
Use Node 24.19.0 and pnpm 9.15.0. Save the complete test below at apps/server/test/public/issue2600.test.ts in both checkouts. The test harness creates and cleans all synthetic state; no live server should be supplied. The following normalized commands reproduce the executed frozen install and forced test graph in separate clean checkouts.
WORK=$(mktemp -d) git clone https://github.com/get-bb/bb.git "$WORK/base" git -C "$WORK/base" worktree add --detach "$WORK/first" e52f3662fb6e52eb85238d22a07d1bac16b6e430 git -C "$WORK/base" worktree add --detach "$WORK/second" e52f3662fb6e52eb85238d22a07d1bac16b6e430 # Save the complete test below in both checkouts. cd "$WORK/first" npm_config_cache="$WORK/npm-first" npm_config_devdir="$WORK/node-gyp-first" XDG_CACHE_HOME="$WORK/cache-first" pnpm install --frozen-lockfile --store-dir "$WORK/dependency-store" pnpm exec turbo run test --filter=@bb/server --force -- issue2600.test.ts --silent=false # Same-agent second clean reproduction: cd "$WORK/second" npm_config_cache="$WORK/npm-second" npm_config_devdir="$WORK/node-gyp-second" XDG_CACHE_HOME="$WORK/cache-second" pnpm install --frozen-lockfile --store-dir "$WORK/dependency-store" pnpm exec turbo run test --filter=@bb/server --force -- issue2600.test.ts --silent=false
Complete regression fixture
import {mkdir,writeFile,readFile} from "node:fs/promises";
import {join} from "node:path";
import {describe,it,expect} from "vitest";
import {getProject,getThread,listPublicProjects,listEnvironments,threads} from "@bb/db";
import {eq} from "drizzle-orm";
import {startTestServer} from "../helpers/test-app.js";
import {seedHost,seedPrimaryHost,seedProjectWithSource,seedThread,seedEnvironment} from "../helpers/seed.js";
import {advanceProjectDeletion} from "../../src/services/projects/project-deletion.js";
async function waitUntil(check:()=>boolean){for(let n=0;n<100;n++){if(check())return;await new Promise(r=>setTimeout(r,5));}expect(check()).toBe(true);}
describe("issue2600 isolated project removal lifecycle",()=>{
it("removes fresh empty projects over real HTTP while preserving nested project sources",async()=>{
const h=await startTestServer();
try{
const host=seedHost(h.deps,{id:"synthetic-host"});seedPrimaryHost(h.deps,host.id);
const outcomes=[];
for(let iteration=0;iteration<20;iteration++){
const root=join(h.config.dataDir,"synthetic-source",String(iteration));await mkdir(join(root,"child-a"),{recursive:true});await mkdir(join(root,"child-b"));await writeFile(join(root,"sentinel.txt"),"owned synthetic source file");
const children=["child-a","child-b"].map(name=>seedProjectWithSource(h.deps,{hostId:host.id,name,path:join(root,name)}).project);
const response=await fetch(h.baseUrl+"/api/v1/projects",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:"Synthetic parent",source:{type:"local_path",hostId:host.id,path:root}})});expect(response.status).toBe(201);const project=await response.json();
expect(h.db.select({id:threads.id}).from(threads).where(eq(threads.projectId,project.id)).all()).toHaveLength(0);expect(listEnvironments(h.db,{projectId:project.id})).toHaveLength(0);
const withEnvironment=iteration%2===1;if(withEnvironment)seedEnvironment(h.deps,{projectId:project.id,hostId:host.id,path:root});
const deletion=await fetch(h.baseUrl+"/api/v1/projects/"+project.id,{method:"DELETE"});expect(deletion.status).toBe(200);expect(await deletion.json()).toEqual({ok:true});
const visibleAfterResponse=listPublicProjects(h.db).some(p=>p.id===project.id);expect(visibleAfterResponse).toBe(false);
await waitUntil(()=>getProject(h.db,project.id)===null);expect(listEnvironments(h.db,{projectId:project.id})).toHaveLength(0);
expect(children.every(p=>getProject(h.db,p.id)?.deletedAt===null)).toBe(true);expect(await readFile(join(root,"sentinel.txt"),"utf8")).toBe("owned synthetic source file");
outcomes.push({iteration,withUnmanagedEnvironment:withEnvironment,status:deletion.status,visibleAfterResponse,physicallyDeleted:true,nestedProjectsPreserved:2,sourceFilePreserved:true});
}
console.log("ISSUE2600",JSON.stringify({case:"fresh-threadless",outcomes}));
}finally{await h.close();}
},30000);
it("handles repeated concurrent DELETE requests without retaining the project",async()=>{
const h=await startTestServer();
try{const host=seedHost(h.deps,{id:"synthetic-host"});const {project}=seedProjectWithSource(h.deps,{hostId:host.id,path:join(h.config.dataDir,"synthetic-parent")});
const responses=await Promise.all(Array.from({length:5},()=>fetch(h.baseUrl+"/api/v1/projects/"+project.id,{method:"DELETE"})));const statuses=responses.map(r=>r.status);expect(statuses).toContain(200);expect(statuses.every(s=>s===200||s===404)).toBe(true);await Promise.all(responses.map(r=>r.text()));await waitUntil(()=>getProject(h.db,project.id)===null);
console.log("ISSUE2600",JSON.stringify({case:"concurrent-delete",statuses,physicallyDeleted:true}));
}finally{await h.close();}
});
it("also removes an idle environment-less thread control",async()=>{
const h=await startTestServer();
try{const host=seedHost(h.deps,{id:"synthetic-host"});const {project}=seedProjectWithSource(h.deps,{hostId:host.id,path:join(h.config.dataDir,"synthetic-with-thread")});const thread=seedThread(h.deps,{projectId:project.id,status:"idle"});
const response=await fetch(h.baseUrl+"/api/v1/projects/"+project.id,{method:"DELETE"});expect(response.status).toBe(200);await response.json();
const advanced=await advanceProjectDeletion(h.deps,{projectId:project.id});expect(advanced).toBe(true);expect(getProject(h.db,project.id)).toBeNull();expect(getThread(h.db,thread.id)).toBeNull();
console.log("ISSUE2600",JSON.stringify({case:"thread-control",status:response.status,advanced,projectDeleted:true,threadDeleted:true}));
}finally{await h.close();}
});
});
Same-agent second clean reproduction: actual evidence
The same agent personally installed and ran the identical final test in a second clean checkout at the same SHA, with fresh SQLite data, files and loopback ports. Both runs exited 0 with three tests passing. All structured outcomes below match exactly. This is a same-agent second clean reproduction, not independent verification. Timing values are test-run metadata, not a production performance claim.
first actual structured output and runner summary
[
{
"case": "fresh-threadless",
"outcomes": [
{
"iteration": 0,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 1,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 2,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 3,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 4,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 5,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 6,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 7,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 8,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 9,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 10,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 11,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 12,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 13,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 14,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 15,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 16,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 17,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 18,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 19,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
}
]
},
{
"case": "concurrent-delete",
"statuses": [
200,
404,
404,
404,
404
],
"physicallyDeleted": true
},
{
"case": "thread-control",
"status": 200,
"advanced": true,
"projectDeleted": true,
"threadDeleted": true
}
]
@bb/server:test: Test Files 1 passed (1) @bb/server:test: Tests 3 passed (3) @bb/server:test: Duration 7.60s (transform 4.43s, setup 1.53s, import 4.58s, tests 1.32s, environment 0ms) Tasks: 9 successful, 9 total Cached: 0 cached, 9 total Time: 39.64s
second actual structured output and runner summary
[
{
"case": "fresh-threadless",
"outcomes": [
{
"iteration": 0,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 1,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 2,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 3,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 4,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 5,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 6,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 7,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 8,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 9,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 10,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 11,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 12,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 13,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 14,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 15,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 16,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 17,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 18,
"withUnmanagedEnvironment": false,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
},
{
"iteration": 19,
"withUnmanagedEnvironment": true,
"status": 200,
"visibleAfterResponse": false,
"physicallyDeleted": true,
"nestedProjectsPreserved": 2,
"sourceFilePreserved": true
}
]
},
{
"case": "concurrent-delete",
"statuses": [
200,
404,
404,
404,
404
],
"physicallyDeleted": true
},
{
"case": "thread-control",
"status": 200,
"advanced": true,
"projectDeleted": true,
"threadDeleted": true
}
]
@bb/server:test: Test Files 1 passed (1) @bb/server:test: Tests 3 passed (3) @bb/server:test: Duration 7.89s (transform 4.63s, setup 1.57s, import 4.86s, tests 1.28s, environment 0ms) Tasks: 9 successful, 9 total Cached: 0 cached, 9 total Time: 39.541s
Corrections, limits and trust boundary
An initial test-control assumption expected an idle environment-less thread to remain until a later lifecycle sweep. That assertion failed because deletion had already completed. Inspection of the actual cleanup branch explained the result; the control was corrected to assert immediate successful cleanup and then repeated in both clean checkouts. The initial failed control is not evidence of the reported bug. The empty-project and concurrent-delete cases already passed in that initial run.
Twenty fresh attempts and one concurrent burst per checkout do not exclude a rare race. The fixture uses synthetic offline host metadata; it does not perform live source discovery, managed environment teardown, full browser confirmation/menu handoff, client cache/realtime reconciliation, HTTPS/reverse-proxy networking, the reporter’s device, or original 0.40.0 release. It checks the immediate public database listing rather than a rendered sidebar. There are no visual claims or screenshots. Those missing original conditions remain a material coverage gap, not a negative result for that UI scenario.
Issue text, code suggestions, links and proposed commands were treated solely as untrusted claims. None was executed or fetched. The test was derived from trusted main implementation and existing test helpers. All files and project/thread records were synthetic and owned by the test; no actual user runtime, external files or secrets were accessed. Raw build logs remain outside the public repository; exact test source and actual structured results are included inline. Only regenerable dependency directories from a completed prior investigation were removed to preserve inodes; its report evidence was retained.