feat(orchestration): add event-sourcing core #28
No reviewers
Labels
No labels
area:config
area:contracts
area:engine
area:eventsourcing
area:frontend
area:git
area:ipc
area:persistence
area:provider
area:scaffold
area:terminal
type:user-story
No milestone
No project
No assignees
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/peon!28
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "boss/4"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Implements the pure
Command → Decider → Events → Projector → ReadModelpipeline described in spec §5 and §7.1.Added
crates/backend/src/orchestration/commands.rs—OrchestrationCommandenum covering projects, threads, turns, provider ingestion, checkpoints, and git.crates/backend/src/orchestration/events.rs—OrchestrationEventenum,StoredEventwrapper (sequence+stored_at), andProviderEvent(TextDelta,ToolCall,ToolResult,ThinkingDelta,TurnComplete,Error,AwaitingApproval).crates/backend/src/orchestration/decider.rs— puredecide(command, model) -> Result<Vec<Event>, DeciderError>, with a descriptive error enum. All validations listed in the issue are covered (duplicate project paths, missing ids, busy/idle thread invariants, turn existence, approval state, checkpoint existence).crates/backend/src/orchestration/projector.rs— pureproject(model, event) -> modelfold, including cascading deletes (project → threads → turns → checkpoints) and streaming chunk accumulation forText/ThinkingBlockmessage content.Tests (56 pass)
decide → projectround-trip asserting read-model consistency acrossCreateProject → CreateThread → StartTurn.Closes #4
Test plan
cargo fmt --all -- --checkcargo clippy --workspace -- -D warningscargo test --workspaceOverall: solid implementation of the pure event-sourcing core. All acceptance criteria from #4 are met — commands, events, decider, projector, and tests are complete. Two issues need fixing before merge.
Two request-changes items below. One is a correctness bug (tool call ID corruption), the other is a design issue that will silently break replay (empty
git_refbaked into immutable events).@ -0,0 +182,4 @@thread_id,turn_id,description,} => {Design issue:
git_ref: String::new()is baked into an immutable event.Events sourced from
CheckpointCreated(here) andChangesCommitted(~line 205) are stored in the event log withgit_ref: "". Because stored events are immutable, replaying the log will always reconstructCheckpointand commit records with an empty git ref — the reactor can never patch this retroactively through normal projection.The pattern that works in event sourcing: the reactor performs the git operation, learns the actual
git_ref, and then emits the enriched event. Options:CheckpointCreated/ChangesCommittedfrom the decider at all. Have theCheckpointReactorand git reactor emit them (with a realgit_ref) after the side-effect completes. The decider can still validate the command and emit a lightweight intent event (e.g.CheckpointRequested) if you need an audit entry before the side-effect.git_ref: Option<String>in both event types, emitNonefrom the decider, and add follow-up events (CheckpointGitRefAssigned { id, git_ref },CommitRefAssigned { ... }) that the reactor emits after the fact.As written, the event store will contain permanently-empty
git_refstrings that make the stored log useless for point-in-time restore.Same issue applies to
ChangesCommittedat ~line 205.@ -0,0 +339,4 @@turn_id: turn.id,message_id: last.id,chunk: content.clone(),}]);Bug: tool call ID is fused into
name, corrupting both fields.This smuggles the provider's
tool_call_idinto thenamefield, so the read model stores"bash#tc_123"instead of"bash". Downstream code (UI rendering, ToolResult matching) has no way to extract the original name or ID without fragile string splitting.The root cause is that
MessageContent::ToolCallin contracts has noidfield. Fix: addid: StringtoMessageContent::ToolCall(contracts crate), then use it here:This PR touches pure decider/projector logic; the contracts change can land in this same PR or as a prerequisite commit.
Overall: solid implementation of the pure event-sourcing core. All acceptance criteria from #4 are met — commands, events, decider, projector, and tests are complete. Two issues need fixing before merge.
Two request-changes items below. One is a correctness bug (tool call ID corruption), the other is a design issue that will silently break replay (empty
git_refbaked into immutable events).@ -0,0 +182,4 @@thread_id,turn_id,description,} => {Design issue:
git_ref: String::new()is baked into an immutable event.Events sourced from
CheckpointCreated(here) andChangesCommitted(~line 205) are stored in the event log withgit_ref: "". Because stored events are immutable, replaying the log will always reconstructCheckpointand commit records with an empty git ref — the reactor can never patch this retroactively through normal projection.The pattern that works in event sourcing: the reactor performs the git operation, learns the actual
git_ref, and then emits the enriched event. Options:CheckpointCreated/ChangesCommittedfrom the decider at all. Have theCheckpointReactorand git reactor emit them (with a realgit_ref) after the side-effect completes. The decider can still validate the command and emit a lightweight intent event (e.g.CheckpointRequested) if you need an audit entry before the side-effect.git_ref: Option<String>in both event types, emitNonefrom the decider, and add follow-up events (CheckpointGitRefAssigned { id, git_ref },CommitRefAssigned { ... }) that the reactor emits after the fact.As written, the event store will contain permanently-empty
git_refstrings that make the stored log useless for point-in-time restore.Same issue applies to
ChangesCommittedat ~line 205.@ -0,0 +339,4 @@turn_id: turn.id,message_id: last.id,chunk: content.clone(),}]);Bug: tool call ID is fused into
name, corrupting both fields.This smuggles the provider's
tool_call_idinto thenamefield, so the read model stores"bash#tc_123"instead of"bash". Downstream code (UI rendering, ToolResult matching) has no way to extract the original name or ID without fragile string splitting.The root cause is that
MessageContent::ToolCallin contracts has noidfield. Fix: addid: StringtoMessageContent::ToolCall(contracts crate), then use it here:This PR touches pure decider/projector logic; the contracts change can land in this same PR or as a prerequisite commit.