Schalter, Zeitfenster und Handoff-Prozent lagen im Usage-Monitor und mussten in SettingsModalViewModel nur load->save durchgeschleift werden, damit ein Settings-Speichern sie nicht zurueckwirft. Jetzt gehoeren sie dem General-Tab, der Monitor ist wieder nur Anzeige + Threshold-Drag, und "Jetzt aktualisieren" sitzt als Icon in der Titelleiste (neuer ModalShell.HeaderActions-Slot).
18 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), Dependencies (n user/MCP-declared predecessors via
task_dependencies, distinct from BlockedByTaskId), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns / PermissionMode (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit, InteractiveSessionId, TicketRef, Number (global immutable human-readable alias).TicketRef(ticket_ref, nullable string) — link to an external ticket, format<provider>:<id>(currently alwaysbandel:<ticketId>). Set by the ticket import, otherwise null. →ClaudeDo.Worker/CLAUDE.mdTickets section.- Status / PlanningPhase / BlockedByTaskId / dependency-set semantics + allowed transitions:
ClaudeDo.Worker/CLAUDE.md→ Status Model. Dependencies(ICollection<TaskDependencyEntity>) + the[NotMapped] DependsOnTaskIdsid projection. Only populated where the queryIncludes it —TaskRepository.GetByIdAsync/GetByNumberAsync/GetByListIdAsyncdo, and so do theTasksIslandViewModelrow-hydration queries. The queue gate itself is raw SQL inQueuePickerand never reads the nav, so a forgotten Include misreports the UI/MCPblockedflag but can never let a blocked task run. ⚠️TaskRepository.UpdateAsyncmarks only the root entity Modified (Entry().State, notUpdate()) —Update()marks the loaded graph, and an all-keytask_dependenciesrow has nothing to SET.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). The one-time backfill for pre-existing rows lived in theAddTaskNumbersmigration, squashed away 2026-08-26.- Legacy status values
Manual/Planning/Planned/Draft/Waitingwere retired. The backfill migration was squashed away (2026-08-26) — a database still holding those values can't be upgraded, see Schema below.
- 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, PermissionMode, TicketProjectId (all nullable).
VerifyCommandis an optional post-merge gate; null/blank = no gate → review-merge.PermissionModeis null = inherit the global default; resolution order task → list → global lives inEffectiveRunConfigResolver.TicketProjectId(ticket_project_id) links the list to a project in the external ticket system; null = not linked →ClaudeDo.Worker/CLAUDE.mdTickets section.- ⚠️
ListRepository.SetConfigAsynccopies the entity verbatim, so every writer must carry the fields it doesn't own (SessionSkills,PermissionMode,TicketProjectId) or they silently reset.UpdateListConfigDto.TicketProjectIdis tri-state (null= keep stored) for exactly that reason — only the list-settings modal sends an explicit value. This bitPermissionModefor real: its update-branch copy was missing fromSetConfigAsyncuntilf0a3a186, so a list's permission-mode override only ever stuck on the list's first save, never on a later one.
- ⚠️
- 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, Kind (PrimeActionKind, columnaction_kind, defaultping), CreatedAt. Recurs on selected weekdays; no date range.PromptOverride's role depends onKind: ignored forPing, appended to the daily-prep prompt forFillMyDay, and the entire prompt forCustom. - 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 - TaskDependencyEntity — one declared "run after" edge: TaskId + DependsOnTaskId, composite PK, table
task_dependencies. Both FKs areON DELETE CASCADE, which is what the old singledepends_on_task_idcolumn got fromON DELETE SET NULL: deleting a predecessor drops the edge so the dependent becomes pickable instead of blocked on a task that's gone. Written only byTaskStateService.SetDependsOnAsync. - 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. |
PowerUseEnabled |
power_use_enabled |
false | PowerUse master switch, toggled in Settings → Execution. ⚠️ Never sufficient alone — a handler run is in PowerUse only when list_config.power_use_allowed is also set. |
ListHandlerMaxHandoffs |
list_handler_max_handoffs |
4 | Cap on a list-handler run's wait↔merge handoff chain (HandoffRoundTracker). 4 = two merge rounds. Independent of PowerUse — applies to interactive runs too. Clamped to min 1 in WorkerHub.UpdateAppSettings and again in Register, because 0 would remove the only bound on a runaway chain. |
PowerUseWindowStart / PowerUseWindowEnd |
power_use_window_* |
null | Optional local-time window (HH:mm) narrowing when PowerUse applies. Both null (or only one set) = round the clock, i.e. byte-identical to the pre-window behaviour; end before start wraps over midnight. Semantics live in the pure PowerUseWindow; checked in InteractiveLaunchSpecService.ComposeHandlerPromptAsync per phase at prompt-build time, so a chain handing off past the end time drops the addendum from there on. Unparseable input is stored as null, not rejected. |
PowerUseHandoffPct |
power_use_handoff_pct |
95 | 5h-usage percentage at which a PowerUse handler session should hand off. Clamped 50..99. Typed in Settings → Execution; the consumer is the handoff prompt. |
All four usage percentages are clamped 0..100 by AppSettingsRepository.UpdateAsync.
Gate/throttle semantics → usage-monitoring.
⚠️ AppSettingsRepository.UpdateAsync copies the incoming entity field by field onto the
singleton row — a property missing from that block never persists, however correctly the caller
filled the entity, and nothing fails. ModelPresets, PowerUseEnabled and
ListHandlerMaxHandoffs were all silently unwritable that way until 2026-09-02. Add a new column
to that block, not just to the entity + the hub.
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). Bulk delete-per-task is theON DELETE CASCADEontask_attachments, not a repository method. - 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~/.claudeDo - AppSettings — loads
~/.claudeDo/ui.config.json(DbPath, SignalRUrl) - AttachmentStore — dependency-free file store, default root
~/.claudeDo/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), 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- Empty repos:
HasHeadCommitAsync(HEAD resolves to a real commit) andEnsureHeadCommitAsync— a freshgit initrepo (unborn HEAD) gets an empty bootstrap commit via plumbing only (mktree/commit-tree/update-ref), so the user's index and working tree stay untouched; identity is passed inline, so it also works with no git config, and a broken HEAD throws instead of guessing - Identity fallback:
CommitAsyncretries once as the ClaudeDo identity when the machine has none configured
⚠️ Revert never resets or rewrites — it always produces a new commit, because the working directory it operates on is shared with other concurrent sessions.
⚠️ Git stdin is BOM-less UTF-8 — Encoding.UTF8 writes a BOM preamble, which git rejects
as input (mktree fails with "input format error", and commit messages would carry it).
Schema
Tables (one per line so parallel migrations don't collide on the same line):
liststasksworktreeslist_configtask_runssubtasksapp_settingsprime_schedulesdaily_notesweek_reportstask_attachmentstask_dependencies
Managed by EF Core migrations in Migrations/. The 40 incremental migrations were squashed into
a single 20260826094154_InitialCreate on 2026-08-26 — the schema history is git history now,
not a migration chain. Consequences:
ClaudeDoDbContext.MigrateAndConfigurebaselines an existing database onto the squashed id (pre-EF databases without a history, and databases whose history still lists the old chain ending at20260825063230_AddPrimeActionKind). A database that stopped mid-chain can no longer be upgraded and throws with a reinstall message instead of being stamped onto a schema it lacks. Covered byMigrationBaselineTests— one of only two tests that run a realMigrate()(TaskDependencyMigrationTestsis the other; every other Data.Tests fixture usesEnsureCreated, which skips migrations entirely). Its fixture builds the legacy database by migrating toInitialCreateand seeding rows with raw SQL:EnsureCreatedwould build today's schema, so every post-squash migration would then re-add an existing column ("duplicate column name"). Assert "nothing pending", never "InitialCreate is the only history row".- Don't hand-edit the squashed migration; add new ones on top as usual.
dotnet ef migrations add <Name> --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --configuration Release—Datais its own startup project (it holds theDesignpackage and a design-time factory), and--configuration Releaseis needed because a running Worker locksDebug.
tasks holds status, planning_phase (default none) and blocked_by_task_id (FK to
tasks.id, ON DELETE SET NULL). The user/MCP-declared predecessors live in task_dependencies
instead — a set, so a task can wait on n of them; see Worker/CLAUDE.md → Status Model for why
it isn't unified with blocked_by_task_id. AddTaskDependencies (2026-09-02) migrated the former
single tasks.depends_on_task_id column into that table (each value became a one-element set) and
dropped it; the Down direction is inherently lossy for more than one edge.
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