|
| 1 | +# Deterministic Mutation Executor |
| 2 | + |
| 3 | +Replace the planner LLM (`planInboxItemWithResponses`) and `processInboxItem` orchestrator with a deterministic executor that operates directly on `pendingWriteOperation`. |
| 4 | + |
| 5 | +## Problem |
| 6 | + |
| 7 | +The current mutation pipeline has a redundant LLM call. `interpretWriteTurn` + `applyWriteCommit` already resolve the user's intent, target entity, and schedule fields into a structured `pendingWriteOperation`. But when it's time to execute, `processInboxItem` calls `planInboxItemWithResponses` — a separate LLM that re-derives all of this from raw text. The `synthesizeMutationText` step even un-structures the resolved fields back into natural language just so the planner can re-parse them. |
| 8 | + |
| 9 | +This causes wrong-entity references, wasted tokens, and added latency. The planner is fully redundant. |
| 10 | + |
| 11 | +## Design |
| 12 | + |
| 13 | +### Approach |
| 14 | + |
| 15 | +Approach B: executor lives in `apps/web`, pure logic stays in `packages/core`. |
| 16 | + |
| 17 | +`executePendingWrite` is an orchestrator in `apps/web/src/lib/server/` that takes a `PendingWriteOperation` plus store/calendar context and deterministically produces a `MutationResult`. No LLM involved. |
| 18 | + |
| 19 | +### `MutationResult` type |
| 20 | + |
| 21 | +Defined in `packages/core`. Replaces `ProcessedInboxResult`. |
| 22 | + |
| 23 | +```ts |
| 24 | +type MutationResult = |
| 25 | + | { outcome: "created"; tasks: Task[]; scheduleBlocks: ScheduleBlock[]; followUpMessage: string } |
| 26 | + | { outcome: "scheduled"; tasks: Task[]; scheduleBlocks: ScheduleBlock[]; followUpMessage: string } |
| 27 | + | { outcome: "rescheduled"; updatedBlock: ScheduleBlock; followUpMessage: string } |
| 28 | + | { outcome: "completed"; tasks: Task[]; followUpMessage: string } |
| 29 | + | { outcome: "archived"; tasks: Task[]; followUpMessage: string } |
| 30 | + | { outcome: "needs_clarification"; reason: string; followUpMessage: string }; |
| 31 | +``` |
| 32 | + |
| 33 | +Outcome maps from `operationKind`: plan (new task) -> created, plan (existing task) -> scheduled, reschedule -> rescheduled, complete -> completed, archive -> archived. |
| 34 | + |
| 35 | +### Executor: `executePendingWrite` |
| 36 | + |
| 37 | +Location: `apps/web/src/lib/server/execute-pending-write.ts` |
| 38 | + |
| 39 | +Input: |
| 40 | + |
| 41 | +```ts |
| 42 | +type ExecutePendingWriteInput = { |
| 43 | + pendingWriteOperation: PendingWriteOperation; |
| 44 | + userId: string; |
| 45 | + tasks: Task[]; |
| 46 | + scheduleBlocks: ScheduleBlock[]; |
| 47 | + userProfile: UserProfile; |
| 48 | + calendar: ExternalCalendarAdapter | null; |
| 49 | + googleCalendarConnection: GoogleCalendarConnection | null; |
| 50 | +}; |
| 51 | +``` |
| 52 | + |
| 53 | +Switches on `operationKind`: |
| 54 | + |
| 55 | +- **`plan`** with `targetRef.entityId` null: new task. Title from `targetRef.description`. Priority/urgency from `resolvedFields.taskFields`. Schedule from `resolvedFields.scheduleFields` via `buildScheduleProposal`. Write to calendar via `scheduleTaskWithCalendar`. Return `outcome: "created"`. |
| 56 | +- **`plan`** with `targetRef.entityId` present: schedule existing task. Look up task by entity ID, check ambiguous titles, build schedule proposal, write to calendar. Return `outcome: "scheduled"`. |
| 57 | +- **`reschedule`**: look up schedule block by `targetRef.entityId` (task ID) from context blocks. Calendar drift detection, `buildScheduleAdjustment`, update calendar event. Return `outcome: "rescheduled"`. |
| 58 | +- **`complete`**: resolve task from `targetRef.entityId`, check ambiguous titles. Return `outcome: "completed"`. |
| 59 | +- **`archive`**: same as complete. Return `outcome: "archived"`. |
| 60 | +- **`edit`**: deferred. Executor switch has a branch that returns `needs_clarification` for now, keeping the seam open for future implementation. |
| 61 | + |
| 62 | +Any branch that can't proceed (missing calendar, ambiguous target, unresolvable entity) returns `outcome: "needs_clarification"`. |
| 63 | + |
| 64 | +### Extracted calendar helpers |
| 65 | + |
| 66 | +Move from `process-inbox-item.ts` into `apps/web/src/lib/server/calendar-scheduling.ts`: |
| 67 | + |
| 68 | +- `scheduleTaskWithCalendar` — calendar event create/update with drift detection and logging |
| 69 | +- `buildRuntimeScheduleBlocks` — merge Atlas blocks with Google Calendar busy periods |
| 70 | +- `filterBusyPeriodsAgainstAtlasTasks` |
| 71 | +- Calendar logging helpers (`logCalendarWriteAttempt`, `logCalendarWriteSuccess`, `logCalendarWriteFailure`) |
| 72 | +- Ambiguous title detection helpers (`hasAmbiguousTaskTitle`, `hasAmbiguousScheduledTitle`, `findActionableTasksWithSameTitle`, `buildAmbiguousTaskReply`) |
| 73 | + |
| 74 | +### Policy simplification |
| 75 | + |
| 76 | +`recover_and_execute` is removed from `TurnPolicyAction`. Confirmations that resolve a proposal now produce `execute_mutation`. The distinction between direct writes and confirmed proposals is no longer meaningful — both execute `pendingWriteOperation`. |
| 77 | + |
| 78 | +Removed fields: |
| 79 | +- `mutationInputSource` from `TurnPolicyDecision` |
| 80 | +- `recover_and_execute` from the action enum |
| 81 | + |
| 82 | +### Proposal confirmation and `pendingWriteOperation` rehydration |
| 83 | + |
| 84 | +When a user confirms a proposal, `pending_write_operation` on discourse state may not match the proposal being confirmed (e.g., the user started a different workflow in between). The proposal entity's `fieldSnapshot` is the durable checkpoint. |
| 85 | + |
| 86 | +On confirmation of a proposal, the turn router rehydrates `pending_write_operation` from the proposal entity: |
| 87 | + |
| 88 | +1. Look up the proposal entity by `resolvedProposalId` |
| 89 | +2. Rebuild `PendingWriteOperation` from `data.fieldSnapshot` (resolvedFields), `data.targetEntityId` (targetRef), `data.operationKind`, and `data.originatingTurnText` |
| 90 | +3. Set as `resolvedOperation` on the policy output |
| 91 | +4. Executor receives it like any other `pendingWriteOperation` |
| 92 | + |
| 93 | +Schema changes to support this: |
| 94 | +- Add `operationKind` to the proposal entity `data` (not stored today) |
| 95 | + |
| 96 | +### Schema changes |
| 97 | + |
| 98 | +**`resolvedFieldsSchema` (`packages/core/src/discourse-state.ts`):** |
| 99 | +- Add `urgency` to `taskFields`: `urgency: z.enum(["low", "medium", "high"]).optional()` |
| 100 | + |
| 101 | +**Proposal entity `data`:** |
| 102 | +- Add `operationKind: OperationKind` |
| 103 | + |
| 104 | +### Webhook wiring |
| 105 | + |
| 106 | +The two mutation branches in `telegram-webhook.ts` collapse to one: |
| 107 | + |
| 108 | +``` |
| 109 | +if (action === "execute_mutation") { |
| 110 | + const result = await executePendingWrite({ |
| 111 | + pendingWriteOperation: policy.resolvedOperation, |
| 112 | + userId, tasks, scheduleBlocks, userProfile, calendar, googleCalendarConnection, |
| 113 | + }); |
| 114 | +} |
| 115 | +``` |
| 116 | + |
| 117 | +`deriveMutationState` in `conversation-state.ts` is updated to consume `MutationResult` instead of `ProcessedInboxResult`. |
| 118 | + |
| 119 | +### Store persistence |
| 120 | + |
| 121 | +Reuse existing store methods (`saveTaskCaptureResult`, `saveTaskCompletionResult`, `saveScheduleRequestResult`, etc.). Drop `plannerRun` field from their input types. No new store methods needed. |
| 122 | + |
| 123 | +## Deletions |
| 124 | + |
| 125 | +**Modules removed:** |
| 126 | +- `apps/web/src/lib/server/process-inbox-item.ts` and tests |
| 127 | +- `packages/integrations/src/prompts/planner.ts` |
| 128 | +- `packages/integrations/src/manual/planner.eval-suite.ts` |
| 129 | +- `packages/core/src/synthesize-mutation-text.ts` and tests |
| 130 | + |
| 131 | +**Exports removed:** |
| 132 | +- `planInboxItemWithResponses` from `@atlas/integrations` |
| 133 | +- `recoverConfirmedMutationWithResponses` from `@atlas/integrations` |
| 134 | +- `ProcessedInboxResult` from `@atlas/db` (replaced by `MutationResult` in `@atlas/core`) |
| 135 | + |
| 136 | +**Types/fields removed:** |
| 137 | +- `recover_and_execute` from `TurnPolicyAction` |
| 138 | +- `mutationInputSource` from `TurnPolicyDecision` |
| 139 | +- `plannerRun` from store input types |
| 140 | +- `ProcessInboxItemDependencies` / `ProcessInboxItemRequest` |
| 141 | + |
| 142 | +## Testing |
| 143 | + |
| 144 | +**New tests (`execute-pending-write.test.ts`):** |
| 145 | +- One test per `operationKind` happy path: plan (new), plan (existing), reschedule, complete, archive |
| 146 | +- Clarification fallbacks: missing calendar, ambiguous title, unresolvable entity |
| 147 | +- Calendar interaction: correct times from `resolvedFields.scheduleFields` |
| 148 | + |
| 149 | +**New tests (proposal rehydration):** |
| 150 | +- Confirmation when `pending_write_operation` was overwritten by different workflow — verifies `fieldSnapshot` used |
| 151 | +- Confirmation when `pending_write_operation` still matches |
| 152 | + |
| 153 | +**Modified tests:** |
| 154 | +- `conversation-state.test.ts` — `MutationResult` instead of `ProcessedInboxResult`, no `plannerRun` |
| 155 | +- `decide-turn-policy.test.ts` — no `recover_and_execute`, confirmations produce `execute_mutation` |
| 156 | +- `telegram-webhook.ts` tests — mock `executePendingWrite` instead of `processInboxItem` |
| 157 | + |
| 158 | +**Deleted tests:** |
| 159 | +- `process-inbox-item.test.ts` |
| 160 | +- Planner eval suite |
| 161 | +- `synthesizeMutationText` tests |
| 162 | + |
| 163 | +**Unchanged:** |
| 164 | +- Turn router, write-commit, calendar helper tests |
0 commit comments