docs(specs): add task-numbers and dependency-chain display designs
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# Dependency chain display — Design
|
||||
|
||||
**Date:** 2026-08-11
|
||||
**Status:** approved, not implemented
|
||||
|
||||
## Problem
|
||||
|
||||
`DependsOnTaskId` is invisible in the UI. `TaskRowViewModel` has no `DependsOnTaskId` property
|
||||
and `TaskRowView.axaml` renders nothing for it — a chained task looks exactly like an unrelated
|
||||
one. The execution order the user declared is not readable anywhere in the list.
|
||||
|
||||
## Target
|
||||
|
||||
A chain renders as a group: the head full width, its dependents indented behind a vertical rail,
|
||||
each carrying a small circular step badge on the rail.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ chain head (full width, no badge) │
|
||||
└──────────────────────────────────────────┘
|
||||
╷ ┌────────────────────────────────────┐
|
||||
(1)│ first dependent │
|
||||
╷ └────────────────────────────────────┘
|
||||
╷ ┌────────────────────────────────────┐
|
||||
(2)│ second │
|
||||
╷ └────────────────────────────────────┘
|
||||
╷ ┌────────────────────────────────────┐
|
||||
(2)│ parallel to the second │
|
||||
╷ └────────────────────────────────────┘
|
||||
╷ ┌────────────────────────────────────┐
|
||||
(3)│ third │
|
||||
└────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Model reading
|
||||
|
||||
- **Step number = hop distance from the chain head**, not a running counter. `DependsOnTaskId` is
|
||||
a single FK, so several tasks may share one predecessor → same depth → **same number**. Two
|
||||
rows showing `2` means "these two are both unblocked by step 1", which is the intended reading.
|
||||
- **Chain head** = a task with no `DependsOnTaskId` that at least one other task points at. A task
|
||||
with no dependents and no dependency is not a chain and renders exactly as today.
|
||||
- Cycles are impossible (`TaskStateService.SetDependsOnAsync` rejects them), so the depth walk
|
||||
always terminates. Still cap the walk defensively.
|
||||
|
||||
## Decisions
|
||||
|
||||
**One indent level only — parent wins.** The 24 px indent track already exists for planning
|
||||
children (`TaskRowView.axaml:22-28`, gated on `ShowAsChild`). A planning child that *also* has a
|
||||
`DependsOnTaskId` keeps the parent indent and shows its chain membership as a small inline
|
||||
chip (`after #123`) instead of a second indent level. No nesting, no 48 px rows.
|
||||
|
||||
**The group is pulled together; the head carries it.** Chain members are re-ordered to sit
|
||||
directly under their head regardless of `SortOrder`, so displayed order always equals execution
|
||||
order. Dragging the head moves the whole group; members are not individually draggable.
|
||||
|
||||
**Head not in view → no orphan rails.** Exact precedent exists: `ParentInView` /`ShowAsChild`
|
||||
(`TaskRowViewModel.cs:75`, computed in `Regroup` at `TasksIslandViewModel.cs:543`). Same
|
||||
treatment — if the head is filtered out (other list, My Day, completed group), the row renders
|
||||
flat with the `after #123` chip instead of a dangling rail.
|
||||
|
||||
**Badge has no `#`.** If task numbers (`2026-08-11-task-numbers-design.md`) also land, a row
|
||||
would show a rail badge and a `#412` title prefix. The rail badge is a step position, not an
|
||||
identity — render it bare (`2`), never `#2`.
|
||||
|
||||
## Slices
|
||||
|
||||
| # | Slice | Depends on |
|
||||
|---|---|---|
|
||||
| 1 | VM: `DependsOnTaskId` + depth/head computation in `Regroup`, group pull-together, drag semantics | — |
|
||||
| 2 | View: rail, step badge, `after …` chip, locale keys | 1 |
|
||||
|
||||
Slice 1 exposes the contract Slice 2 binds to: `ShowAsChainMember`, `ChainStep`,
|
||||
`ChainAfterLabel`.
|
||||
@@ -0,0 +1,116 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user