Repository navigation
Deleted thread keeps its pull-request link, making the PR permanently unopenable #12336
Description
Activity
Triage
Verdict: confirmed bug, still present on current
main(not a duplicate, not fully fixed).
Labels:bug,accepted,via-triageThe report matches the code. A deleted thread can keep (or regain) the
projection_thread_pull_requestsrow for that PR, and a later open/link against the same thread id refresheslinked_atinstead of creating a replacement thread.What already exists
thread.deletedalready drops the thread's PR-link rows, with a projector test for it. That landed in #10839 and is in 0.0.42, the version on the report:case "thread.deleted": { // ... // A tombstoned thread must not show up as linked to a pull request. yield* projectionThreadPullRequestRepository.deleteByThreadId({ threadId: event.payload.threadId, });
The public linked-threads query also skips deleted threads (
t.deleted_at IS NULLinapps/server/src/pullRequest/linkedThreads.ts). The HTTP shell snapshot filters them out, and the WS shell stream emitsthread-removedonthread.deleted.So “delete never clears the link table” is not the whole story on 0.0.42. The timestamps in the report are the rest of it:
deleted_at = 15:20:06Z, thenlinked_atrefreshed at23:49:44Zafter a successfulpreparePullRequestThreadgit path. That refresh is an upsert, not a leftover row that delete failed to touch.Remaining hole
Most thread commands still accept a soft-deleted id.
requireThreadonly checks that the row exists.thread.pull-request.linkuses that helper and does not checkdeletedAt. The projector then upserts the link and overwriteslinked_at.thread.meta.updatecan take the same path via the legacy single-link rewrite. The in-memory projector setsdeletedAtonthread.deletedbut does not clearpullRequests, so the decider still sees the old link.listByPullRequestalso has nodeleted_atfilter. Anything that resolves “which thread owns this PR?” from that table (or from the command read model, which includes deleted threads and their remaining links) can land on the tombstone.That matches this client: iframe +
/api/orchestration/shell+/api/orchestration/dispatch. Official checkout creates a new draft id (openOrReuseProjectDraftThread) and should still be able to spawn a second thread — there is no unique constraint on(host, repository, number). The “permanently unopenable” failure is for a client that treats the existing link / cached thread id as the resolution target. The server will happily re-link that tombstone.OrchestrationThreadShellhassettledAt/snoozedUntil/archivedAtand nodeletedAt. The list snapshot already omits deleted threads, and per-id shell/detail reads usedeleted_at IS NULL. The API gap is real for a client that caches a thread id or mixes shell with the command snapshot (GET /api/orchestrationservesgetCommandReadModel, which does include deleted threads). Dispatch then still accepts commands on that id.Suggested fix
- Keep the existing
deleteByThreadIdonthread.deleted(already there). Also clearpullRequestsin the in-memory projector. - Reject
thread.pull-request.link/thread.pull-request-link.sync/ legacythread.meta.updatelink writes whendeletedAt !== null. Skip deleted threads in every PR→thread lookup (listByPullRequestconsumers and command-read-model resolution), not onlylistLinkedPullRequestThreads. - Optional but useful for embedders: put
deletedAtonOrchestrationThreadShell, or document that shell absence plusthread-removedis the only deletion signal. Filtering the list is not enough if dispatch still mutates the tombstone.
The SQL workaround in the report is the right recovery: delete the orphaned
projection_thread_pull_requestsrow, then restart.Related
No existing issue or later commit covers the re-link-after-delete hole. #10839 only added the delete-time cleanup. Related but distinct: #9085 (worktree cleanup on archived delete), open #8796 (provider_session_runtime binding), closed #7653 (merged PR after remote branch delete).
- Keep the existing
- addedbugSomething is broken or behaving incorrectly.Something is broken or behaving incorrectly.acceptedfeature request acceptedfeature request acceptedvia-triageFiled through npx t3 triageFiled through npx t3 triage
on Sep 18, 2026 Follow-up after working around this on the affected machine. Two things that change the picture.
Deleting the orphaned row does not hold. I deleted it directly in
state.sqliteand confirmed zero rows. The next open attempt recreated it, same deleted thread, three minutes later:deleted by hand 18:58Z (verified: 0 rows) recreated 00:01:33.586Z thread d261fce4-…, deleted_at 15:20:06.731ZNothing rebuilt it from an event log — the client re-sent that thread id, and the server re-linked the pull request to a thread it knows is deleted. So the link is not merely stale state left behind by deletion; it is writable against a deleted thread at any time. A check at link time, rejecting a
thread_idwhosedeleted_atis set, would close this independently of whatever cleanup deletion does.The client could not have known better, and this is the part worth fixing first. The thread id came from the client's own item-to-thread mapping. Before re-sending it, the client verified the thread was live the only way the API allows — it looked for it in
/api/orchestration/shell— and found it, because the shell lists deleted threads and the thread shape has no field that distinguishes one. The client then did exactly what it should with a thread the server says exists.That is the whole loop, and it is closed:
- Client sends thread id from its mapping.
- Server re-links the PR to that deleted thread and refreshes
linked_at. - Client validates against the shell; the deleted thread is present, so validation passes.
- No new thread is ever created. Repeat forever.
The pull request became openable only after clearing both ends: the projection row, and the client's mapping from a browser console. A new live thread was created immediately after, and now coexists with the stale row:
8a4427d4-… live linked 00:05:06Z d261fce4-… deleted 15:20:06Z linked 00:01:33Z ← still presentTwo rows for the same pull request, one pointing at a deleted thread. Whatever the resolution order is, that is worth rejecting at write time.
Ranked by what would have prevented the dead end:
- Reject a link write whose target thread is deleted. Breaks the loop at step 2 even with a client sending a stale id.
- Exclude deleted threads from
/api/orchestration/shell, or give the thread shape adeletedAt. Breaks it at step 3 and lets clients self-heal — without this, no client-side liveness check is possible at all. - Clear a thread's
projection_thread_pull_requestsrows when it is deleted. Prevents the orphan existing, though on its own it does not stop step 2 from recreating one.
We shipped a client-side workaround — forget the mapping and reopen once when a thread we were sent to does not materialise — but it only helps where the shell omits the thread. While the shell reports deleted threads as live, no client can detect this case.
Thanks for taking the time to report this and provide the details. We revisited it during the orchestrator V2 cleanup.
V2 dispatch now rejects metadata and pull-request link/sync commands when thread.deletedAt is non-null. This directly breaks the reporter-confirmed re-link-after-delete loop even if an external client resends a cached tombstone ID.
I’m closing this based on the current source and the evidence in this thread.
Verified server write guard from source. External embedders must refresh stale mappings; historical orphan rows were not inspected.
If you still hit this on a current build, please reply with the app/server versions and the steps that reproduce it. We can reopen this if the original problem is still there.
Summary
A thread's pull-request link outlives the thread. Once the thread is deleted, the row in
projection_thread_pull_requestsremains and continues to be the resolution target for that PR, so the PR becomes permanently unopenable: every attempt resolves to the deleted thread, refreshes itslinked_at, and never creates a replacement.Recovered by deleting the orphaned row by hand. Nothing in the UI offered a way out.
Environment
serverVersion0.0.42, darwin/arm64/api/orchestration/shelland/api/orchestration/dispatchWhat happens
A thread linked to a PR was deleted at
15:20:06Z. For the next eight hours, every attempt to open that PR resolved to the deleted thread:It is the only row for that PR, and no replacement thread was ever created —
projection_threadshas no new row for that project across 90 minutes of repeated attempts, while every other PR in the same project has a live thread.The git side succeeds. Server traces for the 23:49:44Z attempt show
GitVcsDriver.remoteExists,fetchPullRequestHeadCommit, andresolveCommitall exitingSuccess, finishing 82ms beforelinked_atwas refreshed. So the PR head is fetched and resolved, and the work is then attributed to a thread that no longer exists.Expected
Any one of these would have prevented it:
projection_thread_pull_requests.linked_at./api/orchestration/shellexposes deletion, so a client can tell. The thread shape currently carriessettledAt,snoozedUntil,archivedAt-adjacent state but nothing for deletion, so a client that receives a deleted thread cannot distinguish it from a live one. Filtering deleted threads out of the shell would work equally well; exposing the field lets clients report it.Item 3 is what made this undiagnosable from outside: our client added a liveness check — "is this thread still in the shell?" — and it passed, because the deleted thread was indistinguishable from a live one in the projection the client can see.
Reproduction
Expected: a new thread. Actual: resolution to the deleted thread,
linked_atrefreshed, no new thread, nothing openable.Workaround
Restart T3 afterwards. Identifiers for the affected private repository are redacted; thread UUIDs and timestamps are verbatim from the local
state.sqliteon the affected machine.