agenda-task-state-corruption.md8.8 KBView on GitHub # Agenda rewrites corrupt task lifecycle state
Investigation notes for the agenda refactor. No code fix was applied — the affected hook is being
reworked on a separate worktree, so this records the mechanism, the evidence, and the invariants the
new design has to hold. The corrupted data **was** repaired (see [Backfill](#backfill-applied)).
Reported by <email> (2026-08-10): tasks he had already completed kept reappearing on his
agenda and in the Slack digest.
## The mechanism
`writeDocument` on an `agenda` document routes through `writeFileAsYjs`, which diffs the incoming
markdown against the stored Y.Doc and hands the diff to `agendaTaskSyncHook`
(`apps/server/src/services/document-saving/hooks/agenda-task-sync.ts`). The hook writes that diff
back into `user_tasks`.
The hook has **no notion of who produced the change**. Three branches mutate task lifecycle state:
| Line | Diff | Write |
|---|---|---|
| `:148` | `checked` true → false | `status='todo'`, `completed_at=null` |
| `:162` | `deleted` true → false, node unchecked | `status='todo'` (`completed_at` **untouched**) |
| `:242` | node left the document | `status='deleted'` |
| `:177` | node text changed | `description = <node text>` |
That is correct for a human ticking, untombstoning, deleting, or retitling a line in the editor.
But the daily-agenda automation regenerates the whole document every morning via `write-document`.
It composes the markdown itself from `list-tasks` output plus the prior day's prose — emitting
`- [ ]` for **every** task regardless of stored status, omitting whatever it chose not to surface,
and writing a *decorated display label* rather than the stored description. Each of the four branches
above then fires against tasks the user never touched.
`writeFileAsYjs.ts:152-166` already anticipates half of this — it preserves node ids by `taskId` so an
agent rewrite produces `update` diffs instead of delete+create, precisely so the hook does not mark
every row deleted. Nothing stops the agent-origin write from changing the *completion state* those
`update` diffs carry.
## Evidence
Run `aaff38db-a78e-4f66-b105-3e63dd9c1519` (daily agenda, 2026-08-10 21:01–21:08Z), user
`RTBJYppdAJz52zBtuQbUP1hCrbAdPmdN`, document `291badc3-9bd9-4e78-b3a3-0d66103bf4b8`.
**1. `list-tasks` at 21:01:33Z** returned the stored description:
```
TASK 1. [4acaddc9-ffd7-4b8a-8983-c24f92dd337f]
Description: Send initial Slack welcome to Paxos to drive activation
Status: todo
```
**2. `write-document` at 21:07:44Z** (→ v142) emitted these lines for tasks that were **already
completed** at that moment:
```
- [ ] {taskId:"743401b4-5e36-44cc-ad29-43fb019e63ff",dueDate:"2026-07-28",…} Send Vivek (Aristotle) follow-up on Qwen/$10K match — draft ready
- [ ] {taskId:"54511981-c9e5-46b3-ba36-213a11e3bf0f",dueDate:"2026-08-03",…} Send Lior follow-up bump — draft ready
- [ ] {taskId:"fa110e10-bfae-4925-8c02-c65261cac258",dueDate:"2026-07-31",…} Send Michael Pocress OpenRouter migration doc + Automai intro
```
Their `completed_at` values were `2026-08-04 01:01:40`, `2026-08-09 23:38:51`, `2026-08-05 12:04:43`.
They should have rendered `- [x]`. `agenda-markdown.ts:182` maps the glyph straight through:
`const checked = taskMatch[2].toLowerCase() === 'x'`.
**3. Row state immediately after** — all three reverted, inside the same second range as the doc write:
```
id status completed_at updated_at
743401b4 todo 2026-08-04 01:01:40.373 2026-08-10 14:07:40.676
54511981 todo 2026-08-09 23:38:51.812 2026-08-10 14:07:41.764
fa110e10 todo 2026-08-05 12:04:43.557 2026-08-10 14:07:42.165
```
`completed_at` surviving means these took the **`:162`** branch, not `:148` (which nulls it) — they
had been tombstoned to `deleted` by `:242` on an earlier rewrite, and this rewrite re-emitted them
without the tombstone. A completed task can therefore round-trip **done → deleted → todo**.
**4. Same transaction** — ~29 further tasks whose nodes the agent dropped were written to
`status='deleted'` between `14:07:43` and `14:07:44` at uniform ~55 ms intervals, immediately before
the Y.Doc update landed at `14:07:44.932`.
**5. Authorship**, from `document_updates`:
```
from_seq to_seq origin words_before → after started_at ended_at
145 149 human 1790 → 1786 2026-08-11 00:40:02.14Z 2026-08-11 00:42:25.976Z
143 143 agent 1234 → 1234 2026-08-10 14:07:44.932Z 2026-08-10 14:07:44.932Z
137 141 human 1648 → 1645 2026-08-08 19:43:21.048Z 2026-08-08 19:47:38.913Z
```
The `agent` row carries the reverts and tombstones above. The later `human` session on 2026-08-11
`00:40–00:42` is Zach in the agenda — it ticked `233bdefe` ("Create shared Slack channel with
Razorpay") to `done` and it stuck, confirming the **user-driven path works correctly**. Only
agent-origin writes corrupt.
**6. Description clobbering** — `4acaddc9` read `Send initial Slack welcome to Paxos to drive
activation` in step 1 and now reads `Send initial Slack welcome to Paxos — draft ready, $100K ` — the
agent's digest label, trailing space included, written over the canonical title by `:177`. One-way:
the original wording is gone and each rewrite can layer more on.
### How the damage was sized
`completed_at` is a reliable "was genuinely completed at some point" marker — the real cancel path
does not stamp it (`agent_deleted`: 34 of 34,074 rows). So `status IN ('todo','deleted') AND
completed_at IS NOT NULL` isolates rows that were completed and then rewound:
```sql
SELECT status, count(*) total, count(completed_at) with_completed FROM user_tasks GROUP BY 1;
-- before: done 27762/23315 | todo 9428/6 | deleted 8426/1107 | agent_deleted 34074/34
```
**1,113 rows across 38 users** (Zach worst at 165). The true figure is higher — the `:148` branch
nulls `completed_at` as it reverts, erasing its own evidence, so only reverts that took the `:162`
tombstone path are detectable.
## Invariants for the new design
1. **An agent-origin write to an agenda document must not mutate task lifecycle state.** It may
create genuinely-new nodes and update due dates, but `status` and `completed_at` transitions
belong exclusively to human-origin edits. The write origin is already known at this layer
(`document_updates.origin`) — it just isn't threaded into the hook.
2. **Node absence is not deletion.** The agent omitting a task from today's agenda is a rendering
decision. Only a human removing/tombstoning a node should reach `status='deleted'`.
3. **Checkbox glyph must be derived from stored status, not authored.** The daily-agenda render path
should emit `- [${task.status === 'done' ? 'x' : ' '}]`. `daily-agenda-reconciler.ts:189,355`
already does this correctly on the DB→doc direction; the agent's own `write-document` path
bypasses it.
4. **Display label ≠ canonical description.** If the digest wants `— draft ready, $100K`, that
belongs in a presentation attr, not in `user_tasks.description`. Otherwise agent-origin text
write-back must be blocked.
## Backfill applied
All 1,113 rows restored on 2026-08-10:
```sql
UPDATE user_tasks SET status = 'done'
WHERE status IN ('todo','deleted') AND completed_at IS NOT NULL;
-- 1113 rows; todo/deleted now hold 0 rows with a non-null completed_at
```
`updated_at` was deliberately left untouched so incremental-sync watermarks are not perturbed and the
original forensic timestamps survive. A rollback manifest (`id` → prior status) was captured before
the write.
Until invariant 1 lands, **this will recur on the next daily agenda run** for any task completed
between runs.
## Related, not caused by this
Two other reasons Zach's tasks stay open. Neither was touched by the backfill (these rows have no
`completed_at` — they were never marked done in Cedar).
- **Sending a Slack draft never completed its task — FIXED.** `drafts.ts:179` and `mail.ts:1681`
both call `completeTaskByDraftId` after a send. `integrations.slack.sendMessage` accepted the same
`draftId` ("enables attribution") but only passed it to `updateSlackDraftSent` for analytics, so a
Slack-channel task could never be closed by acting on it. **25 Slack tasks with prepared drafts
were stuck in `todo` across 7 users**, including `4acaddc9` (Paxos welcome,
`taskActionData.draftId = slack_draft_1786215541519_ot6wwgb`).
`sendSlackMessage` has exactly one call site, so the added call covers every Slack send surface.
Guarded by `slack-send-completes-task.test.ts`.
- **Nothing closes a task when Cedar observes its action happen outside the app.** Zach posted the
Paxos welcome natively in Slack on 2026-08-08 in his own words; Cedar synced it
(`crm_slack_messages`, channel `C0BNCSV6BQX`, outbound, attributed to the Paxos deal) and still
left the task `todo` three days later. Tracked separately — this is a new capability, not a
regression.