117 lines
5.1 KiB
Markdown
117 lines
5.1 KiB
Markdown
# Task Numbers (`#123`) — Design
|
|
|
|
**Date:** 2026-08-11
|
|
**Status:** approved, not implemented
|
|
|
|
## Problem
|
|
|
|
Tasks are only addressable by GUID. Every report from Claude reads
|
|
"task `0ef5ga…` is done", which is unreadable and untraceable for the user. A short,
|
|
stable, human-speakable handle is needed.
|
|
|
|
## Decision
|
|
|
|
Add a **global, monotonically increasing integer** `TaskEntity.Number`, displayed as `#123`.
|
|
|
|
- **Global, not per list.** Per-list numbering would make `#123` ambiguous across the ~15
|
|
lists and would force a list argument into every lookup. Global costs nothing and is
|
|
unambiguous.
|
|
- **Alias, not identity.** The GUID stays the primary key and stays in branch names
|
|
(`claudedo/{id}`), worktree paths, and the `ClaudeDo-Task:` commit trailer. `Number` is
|
|
purely a display + lookup alias.
|
|
- **Immutable, never reused.** A number that appeared in a log must never later point at a
|
|
different task. Deleting tasks leaves gaps — that is correct and intended.
|
|
|
|
## Allocation
|
|
|
|
`MAX(number) + 1` is **wrong**: deleting the newest task frees its number for reuse.
|
|
Use a persistent counter instead.
|
|
|
|
- `app_settings.next_task_number` (INTEGER NOT NULL, singleton row — same table as
|
|
`MaxTurnsCeiling` etc.).
|
|
- Allocation is one statement, atomic under SQLite's single-writer model:
|
|
`UPDATE app_settings SET next_task_number = next_task_number + 1
|
|
WHERE id = 1 RETURNING next_task_number - 1`
|
|
executed in the **same transaction** as the task insert.
|
|
- `tasks.number` gets a **UNIQUE index** (`idx_tasks_number`) so a collision is a DB error,
|
|
not silent corruption. On a unique violation, retry the allocation (bounded, 5 attempts).
|
|
|
|
Only **two** insert sites exist and both must allocate — every other creation path
|
|
(UI, `add_task`, `batch_add_tasks`, Online Inbox sync, merge-helper handler task,
|
|
planning children) routes through one of them:
|
|
|
|
- `TaskRepository.AddAsync` (`src/ClaudeDo.Data/Repositories/TaskRepository.cs:20`)
|
|
- `TaskRepository.AddChildAsync` (same file, ~line 297)
|
|
|
|
Do **not** reuse `SortOrder` — it is per list and mutable via drag & drop.
|
|
|
|
## Migration + backfill
|
|
|
|
One migration, created **alone** (never in parallel with another migration — sibling
|
|
migrations off the same parent silently drop each other's columns on a SQLite table rebuild):
|
|
|
|
1. `ALTER TABLE tasks ADD COLUMN number INTEGER NOT NULL DEFAULT 0`
|
|
2. Backfill in creation order:
|
|
`UPDATE tasks SET number = (SELECT COUNT(*) FROM tasks t2
|
|
WHERE t2.created_at < tasks.created_at OR (t2.created_at = tasks.created_at AND t2.id <= tasks.id))`
|
|
(or the equivalent `ROW_NUMBER()` window function).
|
|
3. `ALTER TABLE app_settings ADD COLUMN next_task_number INTEGER NOT NULL DEFAULT 1`
|
|
then set it to `(SELECT COALESCE(MAX(number), 0) + 1 FROM tasks)`.
|
|
4. Create the unique index **after** the backfill.
|
|
|
|
⚠️ Tests use `EnsureCreated`, which bypasses migrations — the backfill needs a test that
|
|
explicitly runs `Migrate()` against a DB seeded with pre-migration rows.
|
|
|
|
## Resolution: `#123` → GUID
|
|
|
|
New `TaskIdResolver` in `src/ClaudeDo.Worker/External/`:
|
|
|
|
- Input `#123` or bare `123` (a GUID is never all-digits, so this is unambiguous) → look up
|
|
by number.
|
|
- Anything else → passed through as a GUID.
|
|
- Unknown number → a clear MCP error (`no task with number 123`), never a silent null.
|
|
|
|
Wired at the top of every MCP tool taking a task id (~44 parameters across `External/`),
|
|
including the id arrays of the `batch_*` tools.
|
|
|
|
## Output surface
|
|
|
|
Two central mappers cover most tools:
|
|
|
|
- `ExternalMcpService.ToDto` (line ~1629) → `TaskDto`
|
|
- `ExternalMcpService.GetTaskRefAsync` (line ~335) → `TaskRefDto`
|
|
|
|
Add `Number` to both. Then the DTOs that carry a bare task id and bypass those mappers:
|
|
`RunTaskNowResult`, `MergeTaskResultDto`, `PossibleDuplicateDto`, `SubsetRelationDto`,
|
|
`FileOverlapDto`, the `BatchMcpTools` results, `TaskWaitMcpTools`, `QueueStateMcpTools`,
|
|
`RunHistoryMcpTools`.
|
|
|
|
`McpToolDocs` gets a shared clause instructing the agent to **refer to tasks as `#<number>`
|
|
when reporting to the user**. Without this the number is present in the payload but never
|
|
spoken — this clause is the actual point of the feature.
|
|
|
|
## UI surface
|
|
|
|
- `TaskRowViewModel.Number` → dim `#123` in the row, tokenized (no inline literals).
|
|
- Detail pane header.
|
|
- `HubBroadcaster.WorkerLog` business events in `TaskRunner` / `TaskMergeService` /
|
|
`TaskResetService` say `#123` instead of the GUID.
|
|
|
|
## Out of scope (possible follow-up)
|
|
|
|
Branch names and worktree folder names (`claudedo/123-<slug>`, `123-<slug>`). Cheap once
|
|
numbers exist, but a separate change — it touches merge, self-heal, and the merge helper.
|
|
|
|
## Slices
|
|
|
|
| # | Slice | Depends on |
|
|
|---|---|---|
|
|
| 1 | Schema, counter, allocator, backfill migration + tests | — |
|
|
| 2 | MCP output: `Number` in all task-carrying DTOs | 1 |
|
|
| 3 | MCP input: `TaskIdResolver` + `McpToolDocs` reporting clause | 2 |
|
|
| 4 | UI: row/detail display + worker-log messages | 1 |
|
|
| 5 | Docs: `CLAUDE.md` (Data, Worker), explore-notes | 3, 4 |
|
|
|
|
2 and 3 both edit `ExternalMcpService.cs` heavily, so 3 waits for 2 rather than running
|
|
beside it. 4 touches disjoint files and can run beside 2.
|