feat(domain): orchestrate child task completion

This commit is contained in:
Ashley Venn 2026-06-25 09:50:29 -07:00
parent 57b07b8061
commit be98141ffd
6 changed files with 653 additions and 29 deletions

View file

@ -38,7 +38,7 @@ passed. The current suite has 143 passing tests.
| Surprise work | Completed surprise logging, immediate flexible-task repair, actual occupancy, idempotent replay protection, and completion/locked-hour accounting | Application use cases that persist all resulting changes atomically | 12, 13, 14 |
| Task actions | Flexible and required action services, canonical transition service, internal activity records, idempotent operation handling, completion timestamps/actual intervals, and exactly-once task-stat accounting | Atomic application use cases and persisted activity/stat transaction boundaries | 13, 14 |
| Internal task statistics | Baseline counters, increment helpers, activity-derived counter updates, late/locked completion calculation, and push-before-completion values | Project aggregates, child completion patterns, and atomic persistence | 13, 14 |
| Child tasks | Entry conversion, ownership views, parent completion propagation | Atomic break-up workflow, cycle/direct-child validation, activity/stat updates, application use case | 13, 14 |
| Child tasks | Entry conversion, ownership views, deterministic break-up mutation results, direct-child validation, parent/child completion propagation, lifecycle activities, and idempotent force completion | Application use cases that persist all child-task mutations atomically | 13, 14 |
| Project defaults | Configured static defaults and reminder profile on project | Per-project usage aggregates, deterministic non-blocking learned suggestions, explicit configured-vs-learned resolution | 13 |
| Reminder profiles | Gentle/persistent/strict/silent enum on project | Task override, effective-profile resolver, free-slot suppression directive; platform delivery remains outside the pure core | 13 |
| Timeline state | UI-independent item mapper, locked overlay state, compact selection | Complete Today query/read model, stable per-occurrence IDs, status metadata, correct “next flexible” exclusion, rollover notices | 14 |

View file

@ -118,6 +118,8 @@ orchestration.
Recommended Codex level: high
Status: Complete on 2026-06-25.
Tasks:
- Add a pure domain command/result for breaking one parent into an ordered set of
@ -154,6 +156,13 @@ Acceptance criteria:
- Direct-child/cycle constraints are tested.
- Result objects contain enough mutation data for atomic persistence.
Verification on 2026-06-25:
- `dart format lib test`: passed, 0 files changed after final format
- `dart analyze`: passed, no issues found
- `dart test`: passed, 225 tests
- `git diff --check`: passed
BREAKPOINT: Stop here. Confirm `extra high` mode before adding learned project
statistics and suggestions.

View file

@ -55,7 +55,7 @@ changes from accidental API drift.
|---|---|
| `src/models.dart` | `DomainValidationException`, `Task`, `ProjectProfile`, `TimeInterval` |
| `src/backlog.dart` | `BacklogStalenessSettings`, `BacklogView` |
| `src/child_tasks.dart` | `ChildTaskEntry`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` |
| `src/child_tasks.dart` | `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` |
| `src/document_mapping.dart` | `TaskDocumentExtension`, `TaskStatisticsDocumentExtension` |
| `src/free_slots.dart` | `ProtectedFreeSlotConflict`, `RequiredCommitmentScheduleResult`, `FreeSlotService` |
| `src/locked_time.dart` | `ClockTime`, `LockedBlockRecurrence`, `LockedBlock`, `LockedBlockOccurrence`, `LockedBlockOverride`, `LockedScheduleExpansion`, `expandLockedBlocksForDay`, `lockedSchedulingIntervalsForDay`, `trackCompletedDuringLockedHours`, `completedDuringLockedHoursMinutes` |
@ -101,7 +101,8 @@ Current notable model fields:
difficulty/reminder profile/duration fields
- `TimeInterval`: `start`, `end`, optional `label`
- `TaskStatistics`: skip/push/backlog/missed/cancelled/late/locked-hour,
completed-after-shield, push-before-completion, and parent-child counters
completed-after-shield, and push-before-completion counters. Parent/child
completion patterns are carried as activity metadata.
Known baseline gaps to preserve as explicit later work:
@ -209,6 +210,19 @@ Chunk 13.2 intentionally changed the public API:
- Added `SurpriseTaskLogResult.activities` and routed surprise completion
through the same accounting boundary.
Chunk 13.3 intentionally changed the public API:
- Added `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, and
`ChildTaskBreakUpService`.
- Added `activities` and `transitionOutcomeCodes` to
`ChildTaskCompletionResult`.
- Added `transitionService` injection to `ChildTaskCompletionService`.
- Added `operationId`, existing-activity, and applied-activity parameters to
child and parent completion methods.
- Added `ChildTaskCompletionService.completeParentFromChild`.
- Routed parent/child completion through canonical lifecycle transitions and
parent/child completion metadata.
## Repository contracts
The current repository surface is pure Dart and in-memory only:

View file

@ -42,6 +42,13 @@ Latest Block 13.2 verification result on 2026-06-25:
- `dart test`: passed, 218 tests
- `git diff --check`: passed
Latest Block 13.3 verification result on 2026-06-25:
- `dart format lib test`: passed, 0 files changed after final format
- `dart analyze`: passed, no issues found
- `dart test`: passed, 225 tests
- `git diff --check`: passed
Status values:
- `complete`: Current backend APIs and tests cover the requirement.
@ -66,10 +73,10 @@ Status values:
| MVP-AC-08 | Move backlog items into the soonest flexible slot where they fit and shift later flexible tasks. | `SchedulingEngine.insertBacklogTaskIntoNextAvailableSlot` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart` | complete | Block 12 covers central occupancy, protected Free Slot blockers, structured outcomes, and invariant coverage. |
| MVP-AC-09 | Automatically roll unfinished flexible tasks to tomorrow/top of queue with a small notice. | `SchedulingEngine.rolloverUnfinishedFlexibleTasks` and `SchedulingNotice` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart`, `test/scheduling_invariants_test.dart` | incomplete | Domain movement and structured rollover notices exist; durable next-open rollover notice/read model remains in Block 14. |
| MVP-AC-10 | Log unplanned completed tasks and push overlapping flexible tasks normally. | `SurpriseTaskLogRequest`, `SurpriseTaskLogService.log`, `SurpriseTaskLogResult`, `Task.completedAt` | `test/surprise_task_logging_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart` | incomplete | Block 12 covers actual occupancy and idempotent retry; Chunks 13.1-13.2 add completion timestamps and surprise completion accounting/locked-hour statistics; application use cases remain in Block 14. |
| MVP-AC-11 | Break a large task into child tasks with row-level priority, reward, and duration. | `ChildTaskEntry`, `ChildTaskView`, child creation helpers | `test/child_tasks_test.dart` | complete | Atomic application use case remains in Block 14. |
| MVP-AC-12 | Auto-complete parent tasks when all children are done and allow force-completing all children from the parent or a child. | `ChildTaskCompletionService`, `ChildTaskCompletionResult`, parent-child helpers | `test/child_tasks_test.dart` | complete | Activity/stat updates remain in Block 13. |
| MVP-AC-11 | Break a large task into child tasks with row-level priority, reward, and duration. | `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView` | `test/child_tasks_test.dart` | complete | Chunk 13.3 adds deterministic break-up mutation results; persistence transaction wiring remains in Block 14. |
| MVP-AC-12 | Auto-complete parent tasks when all children are done and allow force-completing all children from the parent or a child. | `ChildTaskCompletionService`, `ChildTaskCompletionResult`, parent-child helpers, `TaskActivity` | `test/child_tasks_test.dart` | complete | Chunk 13.3 routes parent/child completion through lifecycle activities and preserves already-terminal child facts. |
| MVP-AC-13 | Display backlog staleness icons without per-task stale prompts. | `BacklogStalenessMarker`, `BacklogStalenessSettings`, `BacklogView` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | incomplete | Backend marker exists; settings persistence and UI display remain in Blocks 14, 15, and 18. |
| MVP-AC-14 | Track baseline internal statistics needed for later reports and filtering. | `TaskStatistics`, `TaskActivity`, `TaskActivityApplicationResult`, `TaskActivityAccountingService`, statistics increment helpers, selected action services | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/document_mapping_test.dart`, `test/task_lifecycle_test.dart` | incomplete | Chunks 13.1-13.2 add canonical activities plus exactly-once task-stat accounting for movement, late, locked-hour, and push-before-completion values; project aggregates and atomic persistence remain in Blocks 13.4 and 14. |
| MVP-AC-14 | Track baseline internal statistics needed for later reports and filtering. | `TaskStatistics`, `TaskActivity`, `TaskActivityApplicationResult`, `TaskActivityAccountingService`, statistics increment helpers, selected action services | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/document_mapping_test.dart`, `test/task_lifecycle_test.dart`, `test/child_tasks_test.dart` | incomplete | Chunks 13.1-13.3 add canonical activities, exactly-once task-stat accounting, and parent/child completion metadata; project aggregates and atomic persistence remain in Blocks 13.4 and 14. |
## Additional MVP backend requirements

View file

@ -5,6 +5,7 @@
// traversal, or UI-specific state.
import 'models.dart';
import 'task_lifecycle.dart';
import 'time_contracts.dart';
/// Row-style input for creating one child task.
@ -78,6 +79,135 @@ class ChildTaskEntry {
}
}
/// Command input for breaking one parent task into direct children.
class ChildTaskBreakUpRequest {
ChildTaskBreakUpRequest({
required String parentTaskId,
required List<ChildTaskEntry> entries,
required String operationId,
}) : parentTaskId = _requiredTrimmed(parentTaskId, 'parentTaskId'),
entries = List<ChildTaskEntry>.unmodifiable(entries),
operationId = _requiredTrimmed(operationId, 'operationId');
/// Parent task that will own every created child.
final String parentTaskId;
/// Ordered child rows supplied by the caller.
final List<ChildTaskEntry> entries;
/// Idempotency key for the application command that creates the children.
final String operationId;
}
/// Result from breaking a parent task into direct children.
class ChildTaskBreakUpResult {
ChildTaskBreakUpResult({
required List<Task> tasks,
required this.parentTask,
required List<Task> childTasks,
required this.operationId,
List<String> createdChildTaskIds = const <String>[],
List<TaskActivity> activities = const <TaskActivity>[],
}) : tasks = List<Task>.unmodifiable(tasks),
childTasks = List<Task>.unmodifiable(childTasks),
createdChildTaskIds = List<String>.unmodifiable(createdChildTaskIds),
activities = List<TaskActivity>.unmodifiable(activities);
/// Replacement task list including newly created children.
final List<Task> tasks;
/// Parent that owns the created children.
final Task parentTask;
/// Created children in request order.
final List<Task> childTasks;
/// Idempotency key for the command.
final String operationId;
/// Child ids created by this command.
final List<String> createdChildTaskIds;
/// Activity records produced by this command.
///
/// V1 child creation is represented by task inserts, so this is currently
/// empty while still giving application use cases one atomic mutation shape.
final List<TaskActivity> activities;
}
/// Creates direct child tasks from ordered child-entry rows.
class ChildTaskBreakUpService {
const ChildTaskBreakUpService();
/// Break [request.parentTaskId] into direct child tasks.
///
/// This service validates the full requested set before returning any
/// mutations. Persistence and transaction wiring are handled by later blocks.
ChildTaskBreakUpResult breakUp({
required List<Task> tasks,
required ChildTaskBreakUpRequest request,
}) {
final parent = _taskById(tasks, request.parentTaskId);
if (parent == null) {
throw ArgumentError.value(
request.parentTaskId,
'parentTaskId',
'Parent not found.',
);
}
if (request.entries.isEmpty) {
throw ArgumentError.value(
request.entries,
'entries',
'At least one child task is required.',
);
}
final existingIds = tasks.map((task) => task.id).toSet();
final requestedIds = <String>{};
for (final entry in request.entries) {
final childId = entry.id.trim();
if (childId.isEmpty) {
throw ArgumentError.value(entry.id, 'id', 'Child id is required.');
}
if (childId == parent.id) {
throw ArgumentError.value(
entry.id,
'id',
'Child id cannot match the parent id.',
);
}
if (!requestedIds.add(childId)) {
throw ArgumentError.value(
entry.id,
'id',
'Child ids must be unique within one break-up command.',
);
}
if (existingIds.contains(childId)) {
throw ArgumentError.value(
entry.id,
'id',
'Child id is already used by another task.',
);
}
}
final childTasks = request.entries
.map((entry) => entry.toTask(parent: parent))
.toList(growable: false);
return ChildTaskBreakUpResult(
tasks: List<Task>.unmodifiable([...tasks, ...childTasks]),
parentTask: parent,
childTasks: childTasks,
operationId: request.operationId,
createdChildTaskIds:
childTasks.map((child) => child.id).toList(growable: false),
);
}
}
/// Read-only parent/child projection over a task list.
///
/// This is intentionally not a scheduler. It answers ownership questions such
@ -172,12 +302,18 @@ class ChildTaskCompletionResult {
this.completedChildId,
this.completedParentId,
List<String> forceCompletedChildIds = const <String>[],
List<TaskActivity> activities = const <TaskActivity>[],
List<TaskTransitionOutcomeCode> transitionOutcomeCodes =
const <TaskTransitionOutcomeCode>[],
this.autoCompletedParent = false,
this.canCompleteParentExplicitly = false,
}) : tasks = List<Task>.unmodifiable(tasks),
changedTaskIds = List<String>.unmodifiable(changedTaskIds),
forceCompletedChildIds =
List<String>.unmodifiable(forceCompletedChildIds);
List<String>.unmodifiable(forceCompletedChildIds),
activities = List<TaskActivity>.unmodifiable(activities),
transitionOutcomeCodes = List<TaskTransitionOutcomeCode>.unmodifiable(
transitionOutcomeCodes);
/// Replacement task list after the completion action.
final List<Task> tasks;
@ -194,6 +330,12 @@ class ChildTaskCompletionResult {
/// Direct child ids completed by explicit parent-complete behavior.
final List<String> forceCompletedChildIds;
/// Internal activities produced by parent/child completion propagation.
final List<TaskActivity> activities;
/// Transition outcomes returned while applying completion propagation.
final List<TaskTransitionOutcomeCode> transitionOutcomeCodes;
/// Whether the parent was completed because all direct children are complete.
final bool autoCompletedParent;
@ -209,16 +351,23 @@ class ChildTaskCompletionResult {
class ChildTaskCompletionService {
const ChildTaskCompletionService({
this.clock = const SystemClock(),
this.transitionService = const TaskTransitionService(),
});
/// Clock boundary used when a completion timestamp is not supplied.
final Clock clock;
/// Canonical lifecycle transition dependency.
final TaskTransitionService transitionService;
/// Complete one child and auto-complete its parent only when all children are complete.
ChildTaskCompletionResult completeChild({
required List<Task> tasks,
required String childTaskId,
DateTime? updatedAt,
String? operationId,
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
Iterable<String> appliedActivityIds = const <String>[],
}) {
final now = updatedAt ?? clock.now();
final child = _taskById(tasks, childTaskId);
@ -237,27 +386,56 @@ class ChildTaskCompletionService {
throw ArgumentError.value(parentId, 'parentTaskId', 'Parent not found.');
}
final updatedChild = _completedTask(child, updatedAt: now);
final withChildCompleted = _replaceTask(tasks, updatedChild);
final opId =
operationId ?? _defaultOperationId('complete-child', child.id, now);
final childTransition = _completeWithTransition(
task: child,
operationId: opId,
occurredAt: now,
existingActivities: existingActivities,
appliedActivityIds: appliedActivityIds,
metadata: {
'completionSource': 'child',
'parentTaskId': parent.id,
},
);
final withChildCompleted = _replaceTask(tasks, childTransition.task);
final view = ChildTaskView(tasks: withChildCompleted);
final shouldCompleteParent = parent.status != TaskStatus.completed &&
view.parentShouldAutoComplete(parent);
final activities = <TaskActivity>[...childTransition.activities];
final outcomes = <TaskTransitionOutcomeCode>[childTransition.outcomeCode];
if (!shouldCompleteParent) {
return ChildTaskCompletionResult(
tasks: List<Task>.unmodifiable(withChildCompleted),
changedTaskIds:
child.status == TaskStatus.completed ? const [] : [child.id],
changedTaskIds: childTransition.applied ? [child.id] : const <String>[],
completedChildId: child.id,
canCompleteParentExplicitly: parent.status != TaskStatus.completed,
activities: activities,
transitionOutcomeCodes: outcomes,
);
}
final updatedParent = _completedTask(parent, updatedAt: now);
final parentTransition = _completeWithTransition(
task: parent,
operationId: opId,
occurredAt: now,
existingActivities: existingActivities,
appliedActivityIds: appliedActivityIds,
metadata: {
'completionSource': 'autoParentAfterLastChild',
'completedChildTaskId': child.id,
},
);
activities.addAll(parentTransition.activities);
outcomes.add(parentTransition.outcomeCode);
final updatedParent = parentTransition.task;
final completedTasks = _replaceTask(withChildCompleted, updatedParent);
final changedTaskIds = <String>[
if (child.status != TaskStatus.completed) child.id,
parent.id,
if (childTransition.applied) child.id,
if (parentTransition.applied) parent.id,
];
return ChildTaskCompletionResult(
@ -266,6 +444,41 @@ class ChildTaskCompletionService {
completedChildId: child.id,
completedParentId: parent.id,
autoCompletedParent: true,
activities: activities,
transitionOutcomeCodes: outcomes,
);
}
/// Complete a parent from one of its direct children and force-complete siblings.
ChildTaskCompletionResult completeParentFromChild({
required List<Task> tasks,
required String childTaskId,
DateTime? updatedAt,
String? operationId,
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
Iterable<String> appliedActivityIds = const <String>[],
}) {
final child = _taskById(tasks, childTaskId);
if (child == null) {
throw ArgumentError.value(childTaskId, 'childTaskId', 'Task not found.');
}
final parentId = child.parentTaskId;
if (parentId == null) {
throw ArgumentError.value(
childTaskId,
'childTaskId',
'Task is not a child.',
);
}
return completeParent(
tasks: tasks,
parentTaskId: parentId,
updatedAt: updatedAt,
operationId: operationId,
initiatingChildTaskId: child.id,
existingActivities: existingActivities,
appliedActivityIds: appliedActivityIds,
);
}
@ -274,6 +487,10 @@ class ChildTaskCompletionService {
required List<Task> tasks,
required String parentTaskId,
DateTime? updatedAt,
String? operationId,
String? initiatingChildTaskId,
Iterable<TaskActivity> existingActivities = const <TaskActivity>[],
Iterable<String> appliedActivityIds = const <String>[],
}) {
final now = updatedAt ?? clock.now();
final parent = _taskById(tasks, parentTaskId);
@ -283,15 +500,43 @@ class ChildTaskCompletionService {
}
final directChildren = ChildTaskView(tasks: tasks).childrenOf(parent);
if (initiatingChildTaskId != null &&
!directChildren.any((child) => child.id == initiatingChildTaskId)) {
throw ArgumentError.value(
initiatingChildTaskId,
'initiatingChildTaskId',
'Initiating task must be a direct child of the parent.',
);
}
final opId =
operationId ?? _defaultOperationId('complete-parent', parent.id, now);
final forceCompletedChildIds = <String>[];
final changedTaskIds = <String>[];
final activities = <TaskActivity>[];
final outcomes = <TaskTransitionOutcomeCode>[];
final updatedTasks = <Task>[];
for (final task in tasks) {
if (task.id == parent.id) {
final completedParent = _completedTask(task, updatedAt: now);
final transition = _completeWithTransition(
task: task,
operationId: opId,
occurredAt: now,
existingActivities: existingActivities,
appliedActivityIds: appliedActivityIds,
metadata: {
'completionSource':
initiatingChildTaskId == null ? 'parent' : 'parentFromChild',
if (initiatingChildTaskId != null)
'initiatingChildTaskId': initiatingChildTaskId,
},
);
final completedParent = transition.task;
updatedTasks.add(completedParent);
if (task.status != TaskStatus.completed) {
activities.addAll(transition.activities);
outcomes.add(transition.outcomeCode);
if (transition.applied) {
changedTaskIds.add(task.id);
}
continue;
@ -299,9 +544,28 @@ class ChildTaskCompletionService {
final isDirectChild = directChildren.any((child) => child.id == task.id);
if (isDirectChild && task.status != TaskStatus.completed) {
updatedTasks.add(_completedTask(task, updatedAt: now));
final transition = _completeWithTransition(
task: task,
operationId: opId,
occurredAt: now,
existingActivities: existingActivities,
appliedActivityIds: appliedActivityIds,
metadata: {
'completionSource': initiatingChildTaskId == null
? 'parentForce'
: 'childForceParent',
'parentTaskId': parent.id,
if (initiatingChildTaskId != null)
'initiatingChildTaskId': initiatingChildTaskId,
},
);
updatedTasks.add(transition.task);
activities.addAll(transition.activities);
outcomes.add(transition.outcomeCode);
if (transition.applied) {
forceCompletedChildIds.add(task.id);
changedTaskIds.add(task.id);
}
continue;
}
@ -313,6 +577,34 @@ class ChildTaskCompletionService {
changedTaskIds: List<String>.unmodifiable(changedTaskIds),
completedParentId: parent.id,
forceCompletedChildIds: List<String>.unmodifiable(forceCompletedChildIds),
activities: List<TaskActivity>.unmodifiable(activities),
transitionOutcomeCodes: List<TaskTransitionOutcomeCode>.unmodifiable(
outcomes,
),
);
}
TaskTransitionResult _completeWithTransition({
required Task task,
required String operationId,
required DateTime occurredAt,
required Iterable<TaskActivity> existingActivities,
required Iterable<String> appliedActivityIds,
required Map<String, Object?> metadata,
}) {
return transitionService.apply(
task: task,
transitionCode: TaskTransitionCode.complete,
operationId: operationId,
activityId: _activityId(
operationId,
task.id,
TaskActivityCode.completed,
),
occurredAt: occurredAt,
existingActivities: existingActivities,
appliedActivityIds: appliedActivityIds,
metadata: metadata,
);
}
}
@ -439,13 +731,22 @@ List<Task> _replaceTask(List<Task> tasks, Task replacement) {
.toList(growable: false);
}
Task _completedTask(Task task, {required DateTime updatedAt}) {
if (task.status == TaskStatus.completed && task.updatedAt == updatedAt) {
return task;
String _defaultOperationId(String actionName, String taskId, DateTime at) {
return '$actionName:$taskId:${at.toIso8601String()}';
}
return task.copyWith(
status: TaskStatus.completed,
updatedAt: updatedAt,
);
String _activityId(
String operationId,
String taskId,
TaskActivityCode activityCode,
) {
return '$operationId:$taskId:${activityCode.name}';
}
String _requiredTrimmed(String value, String name) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
throw ArgumentError.value(value, name, 'Value is required.');
}
return trimmed;
}

View file

@ -217,6 +217,172 @@ void main() {
});
});
group('Child task break-up command', () {
final now = DateTime(2026, 6, 19, 12);
const service = ChildTaskBreakUpService();
test('creates ordered direct children for one parent', () {
final parent = task(
id: 'parent',
title: 'Clean kitchen',
createdAt: now,
).copyWith(projectId: 'home');
final request = ChildTaskBreakUpRequest(
parentTaskId: parent.id,
operationId: 'break-parent-1',
entries: [
childEntry(
id: 'first',
title: 'Clear sink',
createdAt: now,
),
childEntry(
id: 'second',
title: 'Wipe counter',
createdAt: now,
priority: PriorityLevel.high,
),
],
);
final result = service.breakUp(tasks: [parent], request: request);
expect(result.parentTask, parent);
expect(result.operationId, 'break-parent-1');
expect(result.createdChildTaskIds, ['first', 'second']);
expect(result.childTasks.map((child) => child.id), ['first', 'second']);
expect(
result.childTasks.every((child) => child.parentTaskId == parent.id),
isTrue);
expect(result.childTasks.every((child) => child.projectId == 'home'),
isTrue);
expect(result.tasks.map((task) => task.id), [
'parent',
'first',
'second',
]);
expect(result.activities, isEmpty);
});
test('rejects empty child sets duplicate ids and reused task ids', () {
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
final existingChild = task(
id: 'existing',
title: 'Existing child',
createdAt: now,
parentTaskId: parent.id,
);
expect(
() => service.breakUp(
tasks: [parent],
request: ChildTaskBreakUpRequest(
parentTaskId: parent.id,
operationId: 'empty',
entries: const [],
),
),
throwsArgumentError,
);
expect(
() => service.breakUp(
tasks: [parent],
request: ChildTaskBreakUpRequest(
parentTaskId: parent.id,
operationId: 'duplicate',
entries: [
childEntry(id: 'same', title: 'One', createdAt: now),
childEntry(id: 'same', title: 'Two', createdAt: now),
],
),
),
throwsArgumentError,
);
expect(
() => service.breakUp(
tasks: [parent, existingChild],
request: ChildTaskBreakUpRequest(
parentTaskId: parent.id,
operationId: 'reused',
entries: [
childEntry(id: existingChild.id, title: 'Again', createdAt: now),
],
),
),
throwsArgumentError,
);
});
test('rejects self-parenting and invalid child row fields', () {
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
expect(
() => service.breakUp(
tasks: [parent],
request: ChildTaskBreakUpRequest(
parentTaskId: parent.id,
operationId: 'self',
entries: [
childEntry(id: parent.id, title: 'Self', createdAt: now),
],
),
),
throwsArgumentError,
);
expect(
() => service.breakUp(
tasks: [parent],
request: ChildTaskBreakUpRequest(
parentTaskId: parent.id,
operationId: 'blank-title',
entries: [
childEntry(id: 'child', title: ' ', createdAt: now),
],
),
),
throwsArgumentError,
);
expect(
() => service.breakUp(
tasks: [parent],
request: ChildTaskBreakUpRequest(
parentTaskId: parent.id,
operationId: 'bad-duration',
entries: [
ChildTaskEntry(
id: 'child',
title: 'Clear sink',
createdAt: now,
durationMinutes: 0,
),
],
),
),
throwsA(isA<DomainValidationException>()),
);
});
test('request snapshots entry lists', () {
final entries = [
childEntry(id: 'child', title: 'Clear sink', createdAt: now),
];
final request = ChildTaskBreakUpRequest(
parentTaskId: 'parent',
operationId: 'break',
entries: entries,
);
entries.clear();
expect(request.entries.map((entry) => entry.id), ['child']);
expect(
() => request.entries.add(
childEntry(id: 'other', title: 'Other', createdAt: now),
),
throwsUnsupportedError);
});
});
group('Child task edge-case regressions', () {
final now = DateTime(2026, 6, 19, 12);
@ -438,10 +604,11 @@ void main() {
status: TaskStatus.completed,
parentTaskId: parent.id,
);
final lastChild = task(
final lastChild = scheduledTask(
id: 'last-child',
title: 'Wipe counter',
createdAt: now,
start: DateTime(2026, 6, 19, 11),
parentTaskId: parent.id,
);
@ -456,6 +623,13 @@ void main() {
expect(result.autoCompletedParent, isTrue);
expect(result.completedParentId, parent.id);
expect(result.forceCompletedChildIds, isEmpty);
expect(result.activities.map((activity) => activity.taskId), [
'last-child',
'parent',
]);
expect(result.activities.last.metadata['completionSource'],
'autoParentAfterLastChild');
expect(taskById(result.tasks, 'last-child').stats.completedLateCount, 1);
});
test('parent complete force-completes remaining direct children', () {
@ -492,6 +666,15 @@ void main() {
expect(result.forceCompletedChildIds, ['planned-child']);
expect(result.autoCompletedParent, isFalse);
expect(result.completedParentId, parent.id);
expect(result.activities.map((activity) => activity.taskId), [
'parent',
'planned-child',
]);
expect(result.activities.first.metadata['completionSource'], 'parent');
expect(
result.activities.last.metadata['completionSource'],
'parentForce',
);
});
test('partial child completion does not complete parent', () {
@ -576,6 +759,116 @@ void main() {
expect(result.changedTaskIds, ['parent', 'first-child', 'second-child']);
expect(result.canCompleteParentExplicitly, isFalse);
});
test('parent can be completed from a direct child', () {
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
final initiatingChild = task(
id: 'initiating-child',
title: 'Clear sink',
createdAt: now,
parentTaskId: parent.id,
);
final sibling = task(
id: 'sibling',
title: 'Wipe counter',
createdAt: now,
parentTaskId: parent.id,
);
final result = service.completeParentFromChild(
tasks: [parent, initiatingChild, sibling],
childTaskId: initiatingChild.id,
updatedAt: now,
operationId: 'complete-from-child',
);
expect(taskById(result.tasks, parent.id).status, TaskStatus.completed);
expect(
taskById(result.tasks, initiatingChild.id).status,
TaskStatus.completed,
);
expect(taskById(result.tasks, sibling.id).status, TaskStatus.completed);
expect(result.forceCompletedChildIds, ['initiating-child', 'sibling']);
expect(result.activities.map((activity) => activity.taskId), [
'parent',
'initiating-child',
'sibling',
]);
expect(
result.activities.first.metadata['initiatingChildTaskId'],
initiatingChild.id,
);
});
test('completion retry with existing activities does not duplicate facts',
() {
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
final child = task(
id: 'child',
title: 'Clear sink',
createdAt: now,
parentTaskId: parent.id,
);
final first = service.completeParent(
tasks: [parent, child],
parentTaskId: parent.id,
updatedAt: now,
operationId: 'complete-parent-once',
);
final retry = service.completeParent(
tasks: first.tasks,
parentTaskId: parent.id,
updatedAt: now.add(const Duration(minutes: 10)),
operationId: 'complete-parent-once',
existingActivities: first.activities,
);
expect(retry.changedTaskIds, isEmpty);
expect(retry.activities, isEmpty);
expect(retry.transitionOutcomeCodes, [
TaskTransitionOutcomeCode.duplicateOperation,
]);
expect(taskById(retry.tasks, parent.id).completedAt, now);
expect(taskById(retry.tasks, child.id).completedAt, now);
});
test('force completion preserves already terminal child facts', () {
final completedAt = now.subtract(const Duration(hours: 1));
final parent = task(id: 'parent', title: 'Clean kitchen', createdAt: now);
final completedChild = task(
id: 'completed-child',
title: 'Clear sink',
createdAt: now,
status: TaskStatus.completed,
parentTaskId: parent.id,
).copyWith(
updatedAt: completedAt,
completedAt: completedAt,
);
final cancelledChild = task(
id: 'cancelled-child',
title: 'Wipe counter',
createdAt: now,
status: TaskStatus.cancelled,
parentTaskId: parent.id,
);
final result = service.completeParent(
tasks: [parent, completedChild, cancelledChild],
parentTaskId: parent.id,
updatedAt: now,
);
expect(result.forceCompletedChildIds, isEmpty);
expect(
taskById(result.tasks, completedChild.id).completedAt, completedAt);
expect(
taskById(result.tasks, cancelledChild.id).status,
TaskStatus.cancelled,
);
expect(result.activities.map((activity) => activity.taskId), ['parent']);
});
});
}