refactor(server): project commands commit through EventSinkV2 - #13960
Conversation
Project create, update and delete no longer go through the V1 OrchestrationEngine. ProjectService plans each command with the pure planProjectCommand (ported from the V1 decider) under a per-project lock and commits it through EventSinkV2, which appends the project event, folds it into projection_projects through the new ProjectStoreV2 and records the receipt in one transaction. This removes the last live caller of the V1 engine, so its layer, its three command metrics and their docs go too. With nothing bootstrapping the V1 projection pipeline, the first V2 boot no longer resets auto-pull, env mode, favicon and icon from migration 055's baseline event, and a project whose imported threads were deleted in V2 can be removed without force. Migration 057 restores those four settings on installs that already booted V2, from the project's pre-baseline events. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| new ProjectOperationError({ operation: "dispatch-project-command", projectId, cause }); | ||
| const workspaceRoot = command.type === "project.delete" ? undefined : command.workspaceRoot; | ||
| const planAndCommit = Effect.gen(function* () { | ||
| const project = Option.getOrUndefined(yield* readRow(projectId, { includeDeleted: true })); |
There was a problem hiding this comment.
🟡 Medium project/ProjectService.ts:211
commit accepts project.meta.update and project.delete against a soft-deleted row, so a concurrent update or second delete reports success and appends another event instead of returning ProjectNotFoundError. Because this read uses includeDeleted: true after acquiring the project lock, the planner must treat deleted projects as absent for these command types (or reject them before planning).
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/project/ProjectService.ts around line 211:
`commit` accepts `project.meta.update` and `project.delete` against a soft-deleted row, so a concurrent update or second delete reports success and appends another event instead of returning `ProjectNotFoundError`. Because this read uses `includeDeleted: true` after acquiring the project lock, the planner must treat deleted projects as absent for these command types (or reject them before planning).
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: unavailable · PR result: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR replaces the production project-command pipeline with a new event-sink, receipt, locking, and projection flow while retiring the prior orchestration engine and metrics. The broad runtime and startup behavior changes, including project deletion and persistence semantics, warrant human review. Not approved because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |
Only V2 testers who already booted V2 lost those settings, and a handful of testers can set them again. The upgrade path for everyone else is fixed by no longer replaying the migration 055 baseline through the V1 pipeline. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A rejected project receipt now stores its typed rejection, so retrying a command id after a workspace conflict returns ProjectConflictError even once the root is free. A retried project.delete reaches its receipt instead of failing as not found, and updates or new deletes against a deleted project are rejected as not found instead of appending events. Script-ID rejections no longer persist the raw, unbounded ID. ProjectStore exports make, and its consumers import it as a namespace. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every ProjectStoreV2Error is built by mapError around a failed SQL effect, so the cause is always present. Make it required so a future construction site cannot drop it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Project commands were the last thing still running through the V1
OrchestrationEngine. That engine bootstraps the V1 projection pipeline at startup, which caused two real bugs: the first V2 boot wiped four project settings (Bug 1), and projects whose imported threads had been deleted in V2 could not be removed without force (Bug 2).This is PR 1 of the "projects in V2, V1 deleted" stack against
t3code/codex-turn-mapping.What changed
orchestration-v2/ProjectCommands.ts: a pureplanProjectCommandported from V1decider.ts:225-375. It keeps the V1 behavior: create forcesdefaultModelSelection: null, monograms are limited to 2 graphemes, new script ids must pass thescript.<id>.runcheck, existence checks apply, and an active workspace root must be unique by exact string.project.deleteemits onlyproject.deleted;ProjectServicealready runs the thread cascade in V2. Rejections are typed asProjectCommandInvariantErrororProjectWorkspaceConflictError.orchestration-v2/ProjectStore.ts: a newProjectStoreV2service (Context.Servicewith an inline interface, constructormake). It runs SQL overprojection_projectsand exposesapply(event),get(id, {includeDeleted}),list({projectIds, includeDeleted}),findActiveByWorkspaceRoot(root), and unenrichedgetShell/listShells. Swapping the oldProjectionProjectRepositoryreaders onto it is PR 2.EventSinkV2.commitProjectCommand/commitRejectedProjectCommand: these use the same transaction shape ascommitCommand. The receipt is inserted if absent, thenappendProjectEvent, thenProjectStoreV2.apply, then the receipt is upserted;publishCommittedruns after commit. A reused command id commits nothing and returns its existing receipt.CommandReceiptStoreV2now stores and reads project receipts (getProjectByCommandId).OrchestrationEventStore.appendProjectEvent: writes the unchangedApplicationProjectEventshape (aggregate_kind='project') intoorchestration_events. Project events are not added toOrchestrationV2DomainEvent.ProjectService: normalizes the root, then runs lock → read → plan → commit. Commands are serialized per project, and a command that claims a workspace root also holds that root, so two projects cannot both claim it between read and commit. It also owns the delete cascade (unchanged) and enrichment. It contains no transaction, receipt, append or projection code. Client RPCs and errors are unchanged:ProjectOperationError('dispatch-project-command'),ProjectConflictErrorandProjectNotEmptyError.OrchestrationEngine(Layers and Services),OrchestrationLayerLiveand its uses inorchestration-v2/runtimeLayer.ts, the threet3_orchestration_command*metrics, and their section indocs/operations/observability.md. The tracejqexample now uses theorchestration_v2.*span attributes that are actually emitted. The V1 pipeline and snapshot query remain built but inert; nothing bootstraps the pipeline any more. They are removed in PRs 3 and 4.Bug 1: the first V2 boot reset four project settings
Migration 055's baseline
project.created(ApplicationEventSource.ts:147-158) omitsdefaultThreadEnvMode,autoPull,faviconPathandprojectIcon. It is appended after the V1 projects cursor, so on the first V2 bootProjectionPipeline(:562-577) replays it and resets those columns tonull/false.default_thread_env_mode = 'worktree'. After the boot the row readnull. The value survives only in that project's pre-baselineproject.meta-updatedevent.ProjectSettingsUpgrade.integration.test.tsmigrates to 54, seeds a project with auto-pull, env mode, favicon and icon, then boots the V2 runtime (which runs 055 and 056) on the same file. On the base head it fails with all four columns reset (auto_pull 1→0,default_thread_env_mode 'worktree'→null,favicon_path→null,project_icon_json→null). On this branch the row is unchanged.Bug 2: non-force delete failed after the imported threads were deleted in V2
V1's
project.delete"not empty" check read V1projection_threads, which V2 never writes. The new deletion test imports a V1 thread, deletes it through V2 (planThreadDeletion+commitCommand, asthread.deletedoes), and then removes the project without force. On base this fails withProject '…' is not empty and cannot be deleted without force=true. On this branch it succeeds; the only emptiness check is the V2 shell check inProjectService.No repair migration
Only installs that already booted V2 (the Sept 21-25 preview builds and V2 dev servers, a handful of testers) had these four settings reset. Everyone upgrading from main or nightly (at migration 053/054) never ran 055, and with this PR nothing replays the 055 baseline, so their settings survive. Per the maintainer, the few affected testers can re-set the settings by hand rather than adding a one-off repair migration. V2 stays at migrations 055 and 056.
Heap
I booted the base and the branch on separate VACUUM copies of the live V1 DB (214 threads, 11,802 messages, 404k events). Each ran until the legacy import settled (214 threads, 8,307 messages), then I took three samples of forced-GC
heapUsed:a3fbbe4531The difference is within sample noise. Both copies were deleted afterwards.
Verification
vp exec tsc --noEmit -p .inapps/server,packages/contracts,packages/client-runtime,apps/webandapps/mobile: 0error TS, 0warning TS.vp run knip:check: clean. This PR also removesisOrchestrationCommandRejection, which was only used by the engine.vp linton the touched files: clean, except 7no-unused-varswarnings inobservability/Metrics.tsthat exist on base as well.vp test runon 73 files:src/project/,src/persistence/,src/cli/,src/mcp/toolkits/project/,src/orchestration/Layers/,ProjectCommands,ProjectSettingsUpgrade,LegacyV1Cutover,LegacyV1ThreadImporter,runtimeLayer,DelegatedCompletionDelivery,FoundationPersistence,V1ImportBoundary,OrchestratorReplayFixtures,ProjectionMaintenance*,ws,serverandserverRuntimeStartup. Result: 498/498 passed.ProjectService.test.tscovers same-command-id retry, rejected-receipt replay, workspace conflict, event + row + receipt atomicity (via a trigger-injected row failure), and a gated race between two projects claiming one root. With the workspace lock removed, the race test fails.ProjectCommands.test.tsports the project cases fromdecider.projectScriptsanddecider.projectThreadEnvMode.runtimeLayer.test.ts > usage-limit recovery > manually resumes…(added in fix(server): preserve queued messages after usage limits #13877) writes an interrupted run next to a queued one without holding the queue. A real stop sendsthread.turn.interruptwithholdQueue. Whether the terminal-run worker promotes the queued run before the test's resume depends on fiber scheduling. On base, a single addedEffect.yieldNowmakes both cases fail, and dropping the V1 engine layer changes that timing the same way. The test now writes the queue hold that a real stop produces, and passes on both base and the branch.Left for PRs 2-6
ProjectionSnapshotQuery, and swapProjectionProjectRepositoryforProjectStoreV2.GitManager.ts:723), then deleteProjectionSnapshotQueryandProjectionPipeline.orchestration-v2/legacy/.Model: Claude Opus 5.5 (Claude Code)
🤖 Generated with Claude Code