fix(orchestration): threads imported while the server runs can't be deleted (or acted on at all) - #47
Open
DanielGGordon wants to merge 1 commit into
Open
Conversation
…ets an externally appended aggregate The t3 import CLI runs a second in-process OrchestrationEngine over the same SQLite file while the server is running. Its events and projections land correctly, but the running server's in-memory command read model is hydrated once at startup and only folds events the engine itself commits, so imported threads render in the UI (SQL reads) while every command against them - thread.delete included - is rejected by requireThread with "Thread ... does not exist". The post-failure reconcile cannot recover: it reads events above the in-memory watermark, and the engine's own later appends leapfrog the externally appended sequences. On an aggregate miss for non-create commands, re-read the command read model from the SQL snapshot (the same source as startup hydration), clamped so snapshotSequence/updatedAt never rewind. No sequence-based gate: the engine's own commits advance the projection bookkeeping past external sequences, so such a comparison reads "equal" in exactly the failing case. Regression test simulates the external writer by appending a decider-produced thread.created via the event store + projection pipeline directly, advances the watermark with an unrelated dispatch, and asserts a first-attempt thread.delete succeeds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Deleting a thread appears to do nothing for threads written by the
t3 importCLI while the server is running ("zombie" threads). Reproduced live on prod and deterministically in an isolated dev environment.Symptom chain (captured via Playwright + WebSocket instrumentation):
orchestration.dispatchCommand{"type":"thread.delete","threadId":"claude-import-…"}.thread.meta.updaterejections inorchestration_command_receiptsfrom 07-08 are the same bug).Root cause
t3 importruns a second in-process OrchestrationEngine over the same SQLite file (cli/import.ts→OrchestrationLayerLive+SqlitePersistenceLayerLive). Events, receipts, and projections all land correctly in the DB. But the long-running server's engine keeps its command read model in memory (commandReadModelinOrchestrationEngine.ts), hydrated once at startup fromprojectionSnapshotQuery.getCommandReadModel()and afterwards updated only with events the engine itself commits. Streams appended by the CLI process after server start are invisible to the decider, sorequireThreadfails.The existing
reconcileReadModelAfterDispatchFailurecan't recover: it reads events above the in-memory watermark, and the engine's own later appends (SQLite AUTOINCREMENT sequences) leapfrog the externally appended sequences, leaving them permanently below the watermark. A server restart "heals" existing zombies (startup re-reads SQL), which is why the bug looks intermittent.Fix
In
processEnvelope, after the command-receipt dedupe and beforedecideOrchestrationCommand: if the command's aggregate (thread/project) is missing from the in-memory model and the command isn'tthread.create/project.create(which legitimately target missing aggregates), refreshcommandReadModelfrom the SQL snapshot — the same source startup hydration uses. The refresh is clamped sosnapshotSequence/updatedAtnever rewind (protects the wslatestSequencereplay-gap contract anddispatchStartSequencereconcile semantics). Refresh failure degrades to a log warning and the stale model (command then fails exactly as before).Deliberately no "is SQL newer than memory" sequence gate: the engine's own commits advance the projection bookkeeping past externally appended sequences, so that comparison reads "equal" in exactly the failing case.
commandToAggregateRefalso gains a discriminated-union return type so the thread/project id narrows correctly.The gate is a "must-exist aggregate" computation rather than a blanket create-command exclusion:
project.createhas no must-exist aggregate;thread.create's must-exist aggregate is its project (its decider callsrequireProject, so creating a thread inside an imported project used to fail with "Project … does not exist" — caught in review); every other command checks its own thread/project.Regression tests
OrchestrationEngine.test.ts:thread.createddirectly via the event store + projection pipeline (bypassingengine.dispatch), advances the engine watermark with an unrelated dispatch (the leapfrog), then asserts a first-attemptthread.deletesucceeds.thread.createinto it succeeds.Both are red without the engine change ("… does not exist") and green with it.
Verification
apps/serverorchestration + persistence suites: 33 files / 231 tests pass; typecheck clean; no lint findings on changed files.latestSequencecontract, error-channel typing, layer-memoization in the test harness all verified; its one FIX-FIRST finding —thread.createinto an imported project — is fixed and tested here). Note: fork CI never runs (no Blacksmith runners), so local verification is the gate.node scripts/dev-runner.ts dev, worktree-local.t3): imported a transcript witht3 import claude … --base-dir <dev .t3>while the dev server was running, then deleted it through the UI with Playwright.thread.deletedevent appended, row gone from the sidebar and still gone after reload.Follow-ups (out of scope, noted during diagnosis)
eventPubSub, so connected clients don't see a new import until a reload/resnapshot. Routingt3 importthrough the live server (thet3 projecttryResolveLiveProjectExecutionModepattern) would fix that half and remove the two-writer exposure entirely.PRAGMA busy_timeoutis set anywhere; concurrent writers risk immediateSQLITE_BUSY.🤖 Generated with Claude Code