Skip to content

Commit 9bf333c

Browse files
MaxLinCodeclaude
andcommitted
docs: add deterministic mutation executor design spec
Replaces the planner LLM (planInboxItemWithResponses) and processInboxItem with a deterministic executor that operates directly on pendingWriteOperation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9ede728 commit 9bf333c

1 file changed

Lines changed: 164 additions & 0 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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

Comments
 (0)