Document task-number allocation in Data layer (TaskEntity.Number,
next_task_number counter, invariants, insert paths).
Update Worker docs to clarify TaskIdResolver wiring (#123 → GUID lookup),
Number in MCP payloads, and correct the 'Two hard conventions' statement
(only the first is test-enforced).
Bump external-mcp.md verified-against commit to 38af549 (Slice 4 merge)
and add new sections on task ID resolution and numbering.
Add Slice 4 visual verification items to open.md (row/detail number display,
worker-log messages).
Verified against:
- src/ClaudeDo.Data/TaskNumberAllocator.cs
- src/ClaudeDo.Data/Repositories/TaskRepository.cs (AddAsync line 20, CreateChildAsync line 276)
- src/ClaudeDo.Worker/External/TaskIdResolver.cs
- src/ClaudeDo.Worker/External/ExternalMcpService.cs (TaskDto/TaskRefDto DTOs)
- tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs (only first convention test-enforced)
12 KiB
ClaudeDo.Data
Shared data layer: models, repositories, SQLite infrastructure, and git operations.
Models
- TaskEntity — Id, ListId, Title, Description, Status, PlanningPhase, BlockedByTaskId (FK to predecessor in a chain), DependsOnTaskId (FK to a user/MCP-declared predecessor, distinct from BlockedByTaskId), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit, InteractiveSessionId, Number (global immutable human-readable alias).
- Status / PlanningPhase / BlockedByTaskId / DependsOnTaskId semantics + allowed transitions:
ClaudeDo.Worker/CLAUDE.md→ Status Model. HandlerBaseCommit/HandlerHeadCommit= the review range for a worktree-less "list handler" host task ("Let Claude handle it"), which commits straight into the list's working dir instead of a per-task worktree. Everything that reads a task's diff falls back to this pair wheneverWorktreeis null → conpty-sessions.InteractiveSessionId= the claude session id an embedded ConPTY interactive task session runs under, persisted byInteractiveLaunchSpecServicebefore launch so a closed/aborted session can be resumed → conpty-sessions.Number(INTEGER NOT NULL, unique indexidx_tasks_number) — a global, monotonically increasing display alias for tasks. Never reused: deleting a task leaves a gap in numbering, ensuring that a task number that appears in a log or report always points to the same task (if it exists). Allocated byTaskNumberAllocator.AddWithNumberAsyncon every insert via a persistent counter (app_settings.next_task_number). Why notMAX(number) + 1: deleting the highest-numbered task would free its number for reuse, breaking the immutability guarantee. The allocator uses anUPDATE…RETURNINGstatement to claim numbers atomically, retrying up to 5 times if a uniqueness collision occurs (the counter advances on each attempt, so retries always allocate fresh numbers). ⚠️ Tests useEnsureCreated, which bypasses migrations — the backfill needs a migration that runsMigrate()explicitly (not tested by default; seeworker-task-pipelinenotes).- Legacy status values
Manual/Planning/Planned/Draft/Waitingwere retired; existing rows backfill via theRetireLegacyTaskStatusmigration.
- Status / PlanningPhase / BlockedByTaskId / DependsOnTaskId semantics + allowed transitions:
- ListEntity — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to
IsManual) - ListConfigEntity — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand (all nullable).
VerifyCommandis an optional post-merge gate; null/blank = no gate → review-merge. - WorktreeEntity — TaskId (PK, 1:1), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable — SHA of the merge commit this branch produced; the only thing making
revert_mergepossible without searchinggit log), State (Active|Merged|Discarded|Kept) - TaskRunEntity — per-run record: session_id, turns, result, structured output, exit code, log path, nullable
Model(what the run actually executed with), andTokensIn/TokensOut/CacheReadTokens/CacheWriteTokens. ⚠️ Token fields come from the session transcript, not the stream-json event, as a per-run delta → usage-monitoring. - PrimeScheduleEntity — Id, Days (
[Flags] PrimeDaysweekday bitmask, columndays_of_week), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on selected weekdays; no date range. - DailyNoteEntity — Id, Date (DateOnly), Text, SortOrder, CreatedAt →
daily_notes - WeekReportEntity — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt →
week_reports, unique index on (start_date, end_date) - TaskAttachmentEntity — Id, TaskId (FK, ON DELETE CASCADE), FileName, ByteSize, CreatedAt →
task_attachments - SubtaskEntity, AgentInfo — subtasks / record for scanned agent files
AppSettingsEntity
Beyond the basics it carries:
| Property | Column | Default | Note |
|---|---|---|---|
DefaultMaxTurns |
default_max_turns |
40 | Lowered from 100; AddMaxTurnsCeiling backfilled the seeded row. |
MaxTurnsCeiling |
max_turns_ceiling |
80 | Hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run. UpdateAsync clamps to min 1. |
ModelPresets |
model_presets |
seeded | JSON array of ModelPreset rows. ⚠️ AppSettingsRepository.GetAsync backfills shipping defaults on the first read after it's null, so it's never null once a run has started. |
UsageGateFiveHourPct / UsageGateSevenDayPct |
usage_gate_*_pct |
80 / 90 | Queue pause thresholds; 0 = off. |
UsageThrottle{FiveHour,SevenDay}{Soft,Hard}Pct |
usage_throttle_{five_hour,seven_day}_{soft,hard}_pct |
50 / 65 per bucket | Staged parallelism below the hard gate, per bucket; 0 = that stage off. Edited by dragging the usage-monitor gauges. |
DailyPrepMaxTasks |
daily_prep_max_tasks |
5 | Hard cap on MyDay tasks the daily prep may place. |
ReportExcludedPaths |
report_excluded_paths |
null | JSON array of excluded path prefixes. |
StandupWeekday |
standup_weekday |
Wednesday | int DayOfWeek. |
NextTaskNumber |
next_task_number |
1 | Persistent counter for TaskEntity.Number allocation. Incremented atomically in TaskNumberAllocator.AddWithNumberAsync on every task insert. Monotonic only: the counter itself advances across all tasks (no per-list numbering), and it never resets. A deleted task leaves a gap. |
All four usage percentages are clamped 0..100 by AppSettingsRepository.UpdateAsync.
Gate/throttle semantics → usage-monitoring.
Model / effort registries
- ModelPresets / ModelPreset — per-model run defaults (
Model,Effort,MaxTurns), one row perModelRegistry.Aliasesentry, supplying the global effort and max-turns defaults. Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25.Parse/Serializenormalize (unknown models dropped, missing aliases filled fromDefaults, effort validated, turns clamped 1–200) and never throw — a malformed settings row must not stop a run.For(presets, model, fallbackMaxTurns)always returns a usable row; resolution order and the fallback trap → worker-task-pipeline. - ModelRegistry —
NormalizeAliasis the strict, throwing validator foradd_task/planning model input.TryNormalizeAliasis the non-throwing counterpart for the run path (exact alias match, then substring match against a full model id, elsenull).ByCostAscending= the cost order the prompts use. - EffortRegistry — the
--effortlevels (low|medium|high|xhigh|max) +NormalizeLevel(blank → null = don't pass the flag).
Repositories
All use EF Core LINQ via ClaudeDoDbContext. The atomic Queued → Running claim lives in the
Worker's QueuePicker (FromSqlRaw), not here.
- TaskRepository — CRUD, planning helpers (
CreateChildAsync,SetPlanningStartedAsync,DiscardPlanningAsync,UpdateChildAsync),UpdateAgentSettingsAsync. ⚠️ Status-mutation primitives (MarkRunningAsync/MarkDoneAsync/MarkFailedAsync/FlipAllRunningToFailedAsync) areinternal— only the Worker'sTaskStateServicemay call them.CreateChildAsyncproduces children withStatus=Idle, PlanningPhase=None. Task-number allocation:AddAsync(line 20) andCreateChildAsync(line 276) are the two sole task-creation paths; both callTaskNumberAllocator.AddWithNumberAsyncto allocate a number atomically during the same transaction as the insert. Every other creation path (UI, MCPadd_task,batch_add_tasks, Online Inbox sync, merge-helper handler tasks, planning children) routes through one of these two. - ListRepository — CRUD,
GetConfigAsync/SetConfigAsync(upsert)/DeleteConfigAsyncforlist_config - WorktreeRepository — CRUD,
UpdateHeadAsync,SetStateAsync,SetMergedAsync(atomically sets State=Merged and stamps MergeCommit in one update — the only writer of MergeCommit) - TaskAttachmentRepository —
AddAsync,UpdateAsync,GetAsync(taskId, fileName),ListByTaskIdAsync,DeleteAsync(taskId, fileName),DeleteAllForTaskAsync - DailyNoteRepository, WeekReportRepository, TaskRunRepository, SubtaskRepository, AppSettingsRepository
TaskRepository.DeleteAsync and ListRepository.DeleteAsync also delete the on-disk attachment
dir(s) via an optional AttachmentStore ctor param (defaults to the production store).
Infrastructure
- ClaudeDoDbContext — EF Core DbContext; WAL mode + foreign keys via
UseSqliteoptions - IDbContextFactory<ClaudeDoDbContext> — registered in DI; used by singleton consumers (e.g. the Worker hosted service)
- Paths — expands
~and%USERPROFILE%, resolves relative paths. App root~/.todo-app - AppSettings — loads
~/.todo-app/ui.config.json(DbPath, SignalRUrl) - AttachmentStore — dependency-free file store, default root
~/.todo-app/attachments/<taskId>/.SaveAsyncenforces a 5 MB cap and a path-traversal/containment guard. AlsoDeleteFile,DeleteTaskDir,TaskDir,Root,EnumerateTaskIds(used by the worker orphan sweep). Attachment files live outside git worktrees intentionally.
Git
GitService — async wrapper around the git CLI (ProcessStartInfo, no shell):
- Worktrees: add (serialized to avoid a commondir race), remove, prune, list paths for branch
- Branches: current, list local, checkout, delete
- Staging/commit: status porcelain, add-all, add-path, commit via stdin
- Diffs: working tree, branch vs base, commit range
base..head(shows a merged task's diff after the worktree is gone), per-file, diff-stat, committed files, has-changes - Merge: ff-only, no-ff, abort, mid-merge detection (
MERGE_HEAD), conflicted files - Revert:
RevertMergeCommitAsync(git revert --no-edit -m 1 <sha>),RevertAbortAsync,IsMidRevertAsync(REVERT_HEAD, mirrorsIsMidMergeAsync) PreviewMergeAsync(non-destructive check viagit merge-tree --write-tree),CountChangedFilesAsync, rev-parse, is-git-repo
⚠️ Revert never resets or rewrites — it always produces a new commit, because the working directory it operates on is shared with other concurrent sessions.
Schema
Tables (one per line so parallel migrations don't collide on the same line):
liststasksworktreeslist_configtask_runssubtasksapp_settingsprime_schedulesdaily_notesweek_reportstask_attachments
Managed by EF Core migrations in Migrations/ — ls Migrations/ is the authoritative history;
don't maintain a changelog here. tasks holds status, planning_phase (default none),
blocked_by_task_id (FK to tasks.id, ON DELETE SET NULL), and depends_on_task_id (same FK
shape, but a separate column — see Worker/CLAUDE.md → Status Model for why it isn't unified with
blocked_by_task_id).
Conventions
- Enum ↔ string mapping via EF Core
ValueConverter, configured inIEntityTypeConfiguration<T> - Entity configurations live in
Configuration/ - Primary keys are
init-only strings (GUIDs assigned at creation) - All methods are async with CancellationToken where applicable