Add recurring tasks - #16
Conversation
- proposal.md: Define why, what changes, and capabilities - specs/: Behavior contracts for recurring task creation, lifecycle, listing - design.md: Technical approach with separate vertical slices - tasks.md: Implementation checklist with 28 trackable tasks
|
|
||
| [McpServerTool(Name = "complete_recurring_task", UseStructuredContent = true, OutputSchemaType = typeof(McpTaskResponse))] | ||
| [Description("Use when the user says an active recurring-task instance is finished. Marks it done and schedules the next instance from the template's recurrence.")] | ||
| public Task<CallToolResult> CompleteRecurringTask([Description("Identifier of the recurring-task instance returned by list_recurring_tasks or the morning report.")] long id, CancellationToken cancellationToken) => |
There was a problem hiding this comment.
complete_recurring_task takes an instance (task) id, not a template id. list_recurring_tasks returns template ids, so this description misdirects the model: a template id passed here 404s (TaskNotFoundException) or, worse, completes an unrelated task whose instance id happens to equal the template id. Point the model at list_one_shot_tasks or the morning report for the instance id instead.
There was a problem hiding this comment.
Fixed. The complete tool now points the model at list_one_shot_tasks or the morning report for the instance id, not list_recurring_tasks (template ids).
| Run(async () => McpRecurringTemplateResponse.From(await mediator.Send(new CancelRecurringTaskCommand(id), cancellationToken))); | ||
|
|
||
| [McpServerTool(Name = "list_recurring_tasks", ReadOnly = true, UseStructuredContent = true, OutputSchemaType = typeof(McpRecurringTemplateResponse[]))] | ||
| [Description("Use to discover recurring task templates. Each returned id is the identifier required by recurring lifecycle tools.")] |
There was a problem hiding this comment.
list_recurring_tasks returns template ids, which are only valid for pause/resume/cancel (template lifecycle). complete_recurring_task needs an instance (task) id. Consider scoping this description to the template-lifecycle tools so the model does not hand a template id to complete_recurring_task.
There was a problem hiding this comment.
Fixed. The list_recurring_tasks description now scopes the returned ids to pause/resume/cancel (template lifecycle) and points to list_one_shot_tasks or the morning report for instance ids used by complete_recurring_task.
| ?? throw new RecurringTaskNotFoundException(task.RecurringTaskId.Value); | ||
|
|
||
| var nextDueDate = RecurrenceCalculator.CalculateNextDue( | ||
| DateOnly.FromDateTime(updated.CompletedAt!.Value.Date), |
There was a problem hiding this comment.
Next-due date is derived from the UTC completion date (updated.CompletedAt!.Value.Date), but CompletedAt is clock.UtcNow (offset 0). For the default Europe/Helsinki timezone, completing a task after ~22:00 local yields a UTC date one day behind the user local date, shifting the next due date a day early. This is inconsistent with CreateRecurringTaskHandler.Today(), which converts to clock.TimeZone before taking the date. Consider converting the completion instant to clock.TimeZone first.
There was a problem hiding this comment.
Fixed in CompleteRecurringTaskHandler. Next-due is now derived from the completion instant converted to clock.TimeZone (DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(completedAt, clock.TimeZone).Date)), matching CreateRecurringTaskHandler.Today(). Added a regression test (Helsinki, 01:30 local completion) asserting the next due date uses the local date.
|
|
||
| // Calculate next due date | ||
| var nextDueDate = RecurrenceCalculator.CalculateNextDue( | ||
| DateOnly.FromDateTime(updated.CompletedAt!.Value.Date), |
There was a problem hiding this comment.
Same timezone issue as CompleteRecurringTaskHandler: CompletedAt!.Value.Date is the UTC date. In Europe/Helsinki, late-evening completions produce a next-due date one day early. Consider converting CompletedAt to clock.TimeZone before taking the date.
There was a problem hiding this comment.
Fixed in CompleteOneShotTaskHandler with the same timezone conversion, plus a matching Helsinki regression test. Both handlers now agree with the configured-timezone date used elsewhere.
| var daysInMonth = DateTime.DaysInMonth(year, month); | ||
|
|
||
| // Handle edge case where day doesn't exist in target month | ||
| if (day > daysInMonth) |
There was a problem hiding this comment.
Because next-due is always recomputed from the completion date (never an anchored original date), month-end clamping causes permanent drift: a monthly 31st task completed Jan 31 -> Feb 28 -> Mar 28 -> Apr 28. This is consistent with the completion-date + interval design, so flagging as a behavior note in case the drift is unintended.
There was a problem hiding this comment.
Agreed, and this drift is intended per the spec/design. Both specs (one-shot-task-lifecycle and recurring-task-lifecycle) define the next due as completion date + recurrence interval — the interval is recomputed from each completion, not anchored to an original date. The design also explicitly scopes out "complex recurrence rules" as a non-goal, so we're keeping completion-date + interval as-is. Not changing code here; flagging as a known behavior note.
|
Thanks — verified the fixes:
The month-end clamping note in RecurrenceCalculator is unchanged and remains consistent with the documented "completion date + interval" design. LGTM. |
Summary
Adds recurring task support: templates that repeatedly generate one-shot task instances (weekly meetings, monthly bills, etc.). Creating a template immediately creates the first instance due on the start date; completing an instance schedules the next one. Templates never appear in reports — only their generated instances do.
Implements OpenSpec change add-recurring-tasks (35/35 tasks,
openspec validateclean).What changes
Nagger.Core):RecurringTaskTemplate,RecurrenceRule/RecurrenceUnit,RecurringTaskStatus,RecurringTaskNotFoundException, plusIRecurringTaskTemplateStoreport and aGetByRecurringTaskIdAsynconITaskStore.RecurrenceCalculator.CalculateNextDuewith month-end clamping (e.g. Jan 31 + 1 month → Feb 28).POST /tasks/{id}/completenow spawns the next instance.recurring_task_templatestable and nullablerecurring_task_idonone_shot_tasksvia EF migration20260806160229_AddRecurringTasks.POST /tasks/recurringGET /tasks/recurringPOST /tasks/recurring/{id}/completePOST /tasks/recurring/{id}/pause·/resume·/cancelcreate_recurring_task,complete_recurring_task,pause_recurring_task,resume_recurring_task,cancel_recurring_task,list_recurring_tasks.USAGE.mddocuments the new endpoints and tools.Notable fixes
DateOnlyExtensions.ToDateTimeOffsetso instance due dates use the configured timezone rather than the system-local timezone.ApiExceptionHandlernow mapsRecurringTaskNotFoundExceptionto404(was falling through to 500).net8.0target downgrade ofNagger.Core; keptAllowMissingPrunePackageDatain the Host project, which is required to build in this environment.Verification
dotnet build Nagger.slnx— cleandotnet test Nagger.slnx— 120/120 pass (63 Core unit + 57 Host integration, incl. new recurring API/MCP/Core tests)dotnet ef migrations has-pending-model-changes— noneopenspec validate add-recurring-tasks— valid