Independent exact-head review of d55ac5ebc0ebab8ab1f7cb08a8c44ce5029a2c62 against ee8cfbcc14fc079f0f8c65319da44699cad3eb8e. The T-1620 implementation and regressions are byte-identical to the prior reviewed commit 45a5e83de2058a715a4f31770215cea7321d479d.
45a5e83.30836582905 succeeded on d55ac5e.make lint and make test-quick passed; no source files were edited during this review.Ready to push
No actionable findings. Task and milestone terminal-fetch failures map to distinct established INTERNAL_ERROR envelopes; valid empty reports retain the unchanged report-building and Markdown-formatting path. The exact head matches the PR metadata and the fresh successful CI run.
4063d901a4f948afffbb10a204cc2985fa5ca9d8 T-1620: Add report fetch failure regressions c0b6019bcf15df14ca40d4883e6c8ce389d760f4 T-1620: Surface report fetch failures 56f021c6eaf6c662b6a33e4ea69d7781adfc9697 T-1620: Clarify iOS validation result d55ac5ebc0ebab8ab1f7cb08a8c44ce5029a2c62 Fix T-1620 report intent test determinism Generating a report used to make a fetch failure look like an empty report. It now tells the caller that the task or milestone data could not be read. A real empty result still produces the same friendly empty-report Markdown.
GenerateReportIntent receives narrow, throwing task and milestone fetch interfaces. It handles each source independently, emitting the existing JSON error envelope for a failed read and otherwise passing both successful collections to the existing ReportLogic and ReportMarkdownFormatter pipeline. Injectable now makes date-dependent output deterministic.
The two throwing seams preserve the key three-state distinction: task fetch failed, milestone fetch failed after a task success, or both sources succeeded with zero terminal records. The last case follows the unchanged formatter/logic blobs from the base commit; the first two return stable source-specific hints through unchanged IntentError.json. No new persistence, actor-crossing, or serialization path was introduced.
Transit/Transit/Intents/GenerateReportIntent.swift
Why it matters. Prevents a storage failure from being misrepresented as successful empty Markdown to Shortcuts and CLI callers.
What to look at. GenerateReportIntent.execute:39-69
Transit/Transit/Intents/Shared/TerminalReportFetching.swift
Why it matters. Allows tests to independently inject task and milestone storage failures without changing the production services.
What to look at. TerminalTaskFetching and TerminalMilestoneFetching:3-17
Transit/TransitTests/GenerateReportIntentTests.swift
Why it matters. Guards both error sources and ensures valid empty data still uses the report formatter with the requested date range.
What to look at. GenerateReportIntentTests:66-114
CHANGELOG.md
Why it matters. Makes the observable correction discoverable for callers that previously interpreted false empty reports as success.
What to look at. CHANGELOG.md:12-14
A task failure returns INTERNAL_ERROR / Failed to fetch terminal tasks before the milestone fetch runs. A milestone failure returns its corresponding source-specific envelope. This avoids a false successful report and makes retry diagnosis precise.
Successful reads still call the pre-existing ReportLogic.buildReport and ReportMarkdownFormatter.format. Those files, plus ReportDateRange and IntentError, are byte-identical to the base.
Click to expand.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 99f9fd3..015102e 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -9,10 +9,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - T-1770: JSON mutation intents now preserve their established typed validation and domain-error payloads while mapping untyped SwiftData fetch/save failures to `INTERNAL_ERROR`. Shared task and milestone identifier resolution uses deterministic injected fetchers; milestone mutations also expose an injected saver. Regression coverage spans create, status update, milestone update, and deletion paths, asserting both the internal-error envelope and no persisted mutation on failure. - T-1803: `UpdateStatusIntent` now includes a missing requested `displayId` in its `TASK_NOT_FOUND` hint (`No task with displayId N`), while missing UUIDs retain the existing generic lookup hint and malformed identifiers, duplicate IDs, status validation, and atomic mutation behavior remain unchanged. ### Fixed - T-1613: MCP `query_tasks` now returns the exact tool error `Failed to fetch comments: <error>` when comment serialization cannot read storage, rather than reporting a successful task with `comments: []`. The detailed display-ID and task-list paths share this throwing serialization boundary; genuine empty comment collections retain their successful `comments: []` response. `update_task_status` continues serializing the `Comment` returned by its atomic mutation directly (T-1823), so it performs no post-commit comment fetch that could prompt a retry or duplicate a persisted comment. Deterministic MCP regressions cover both query errors, legitimate empty comments, and zero status-response fetches.+- T-1620: `GenerateReportIntent` now returns the established `INTERNAL_ERROR` JSON envelope with a source-specific stable hint when terminal task or milestone fetches fail, instead of false empty-report Markdown. Successful empty reports retain their existing Markdown and date-range formatting; deterministic regressions cover both failures and the valid-empty response. - T-1608: MCP `query_tasks` now surfaces a tool error rather than a false successful `[]` when either unscoped milestone-name resolution (`Failed to fetch milestones: <error>`) or `milestoneDisplayId` resolution (`Failed to look up milestone: <error>`) cannot read storage. It validates every filter shape before milestone resolution, so malformed filters return their validation error before any milestone lookup outcome. Valid no-match arrays, cross-project same-name aggregation, project-scoped milestone lookup and T-1938 ambiguity handling, response shape, and later task-fetch errors are unchanged; deterministic MCP coverage asserts both exact errors and the legitimate no-match response. - T-1607: Visual App Entity project/task queries and task-creation result resolution now rethrow storage fetch failures through their existing `async throws` contract instead of returning successful empty arrays. Deterministic regression coverage spans `entities(for:)` and `suggestedEntities()` for all three query types, while valid empty/invalid identifier results, ordering, suggestion limits, and partial relationship skipping remain unchanged. - Display-ID collision guards now build candidate-blocking sets from committed task/milestone IDs fetched through a fresh transient `ModelContext`, unioned with live/pending registered main-context values and allocator-issued IDs (T-1939). The shared guard applies to task and milestone creation, provisional promotion, and duplicate repair, so a stale registered bystander can no longer hide a peer-synced committed ID. Regression coverage commits the peer value through an independent context, proves the receiving bystander remains clean and unrefreshed, and separately verifies unsaved IDs remain blocked. Either store view failing still fails closed; allocation serialization, cancellation, maintenance stale-loser probes, and selective save recovery are unchanged. - MCP `create_task` and JSON `CreateTaskIntent` now reject present non-object `metadata` values (string, array, number, boolean, or null) with field-specific errors before task insertion, while omitted metadata and T-723's tolerant handling of values inside valid objects remain unchanged (T-1991). - T-2036: `QueryTasksIntent` now treats empty `completionDate` and `lastStatusChangeDate` objects as valid no-op filters, including when both are present. Raw-object emptiness is preserved before Codable decoding, while nested nulls, malformed non-empty objects, strict dates, reversed ranges, open bounds, and relative precedence remain covered by regression tests.
diff --git a/Transit/Transit/Intents/GenerateReportIntent.swift b/Transit/Transit/Intents/GenerateReportIntent.swiftindex 63f8ff7..14ae693 100644--- a/Transit/Transit/Intents/GenerateReportIntent.swift+++ b/Transit/Transit/Intents/GenerateReportIntent.swift@@ -33,28 +33,39 @@ struct GenerateReportIntent: AppIntent { @MainActor static func execute( dateRange: ReportDateRange, taskService: TaskService,- milestoneService: MilestoneService+ milestoneService: MilestoneService,+ taskFetcher: (any TerminalTaskFetching)? = nil,+ milestoneFetcher: (any TerminalMilestoneFetching)? = nil,+ now: Date = .now ) -> String {+ let taskFetcher = taskFetcher ?? taskService+ let milestoneFetcher = milestoneFetcher ?? milestoneService let tasks: [TransitTask]+ do {+ tasks = try taskFetcher.fetchTerminalTasks()+ } catch {+ Logger(subsystem: "com.transit", category: "report")+ .error("Failed to fetch terminal tasks: \(error.localizedDescription)")+ return IntentError.internalError(hint: "Failed to fetch terminal tasks").json+ }+ let milestones: [Milestone] do {- tasks = try taskService.fetchTerminalTasks()- milestones = try milestoneService.fetchTerminalMilestones()+ milestones = try milestoneFetcher.fetchTerminalMilestones() } catch { Logger(subsystem: "com.transit", category: "report")- .error("Failed to fetch data for report: \(error.localizedDescription)")- let emptyData = ReportData(- dateRangeLabel: dateRange.labelWithDates(),- projectGroups: [],- totalDone: 0,- totalAbandoned: 0- )- return ReportMarkdownFormatter.format(emptyData)+ .error("Failed to fetch terminal milestones: \(error.localizedDescription)")+ return IntentError.internalError(hint: "Failed to fetch terminal milestones").json } - let report = ReportLogic.buildReport(tasks: tasks, milestones: milestones, dateRange: dateRange)+ let report = ReportLogic.buildReport(+ tasks: tasks,+ milestones: milestones,+ dateRange: dateRange,+ now: now+ ) return ReportMarkdownFormatter.format(report) } }
diff --git a/Transit/Transit/Intents/Shared/TerminalReportFetching.swift b/Transit/Transit/Intents/Shared/TerminalReportFetching.swiftnew file mode 100644index 0000000..9e72797--- /dev/null+++ b/Transit/Transit/Intents/Shared/TerminalReportFetching.swift@@ -0,0 +1,17 @@+import Foundation++/// Narrow seams over terminal report fetches so report generation can distinguish+/// storage failures from a successful empty result.+@MainActor+protocol TerminalTaskFetching {+ func fetchTerminalTasks() throws -> [TransitTask]+}++extension TaskService: TerminalTaskFetching {}++@MainActor+protocol TerminalMilestoneFetching {+ func fetchTerminalMilestones() throws -> [Milestone]+}++extension MilestoneService: TerminalMilestoneFetching {}
diff --git a/Transit/TransitTests/GenerateReportIntentTests.swift b/Transit/TransitTests/GenerateReportIntentTests.swiftindex 73d07f1..7f458d5 100644--- a/Transit/TransitTests/GenerateReportIntentTests.swift+++ b/Transit/TransitTests/GenerateReportIntentTests.swift@@ -11,10 +11,28 @@ struct GenerateReportIntentTests { let context: ModelContext let taskService: TaskService let milestoneService: MilestoneService } + private struct FetchFailure: Swift.Error {}++ private struct FailingTerminalTaskFetcher: TerminalTaskFetching {+ func fetchTerminalTasks() throws -> [TransitTask] { throw FetchFailure() }+ }++ private struct EmptyTerminalTaskFetcher: TerminalTaskFetching {+ func fetchTerminalTasks() throws -> [TransitTask] { [] }+ }++ private struct FailingTerminalMilestoneFetcher: TerminalMilestoneFetching {+ func fetchTerminalMilestones() throws -> [Milestone] { throw FetchFailure() }+ }++ private struct EmptyTerminalMilestoneFetcher: TerminalMilestoneFetching {+ func fetchTerminalMilestones() throws -> [Milestone] { [] }+ }+ private func makeEnv() throws -> TestEnv { let testContainer = try makeReportTestContainer() let ctx = testContainer.context let store = InMemoryCounterStore() return TestEnv(@@ -32,32 +50,69 @@ struct GenerateReportIntentTests { let now = reportTestNow let project = makeTestProject(name: "Alpha", context: env.context) makeTerminalTask(name: "Ship feature", project: project, completionDate: now, context: env.context) let markdown = GenerateReportIntent.execute(- dateRange: .thisYear, taskService: env.taskService, milestoneService: env.milestoneService+ dateRange: .thisYear,+ taskService: env.taskService,+ milestoneService: env.milestoneService,+ now: reportTestNow ) #expect(markdown.contains("# Report: This Year")) #expect(markdown.contains("Ship feature")) #expect(!markdown.contains("No tasks completed or abandoned in this period.")) } - @Test("Empty date range returns empty-state Markdown")- func emptyDateRangeReturnsEmptyState() throws {+ @Test("Task fetch failure returns the exact INTERNAL_ERROR payload")+ func taskFetchFailureReturnsInternalError() throws { let env = try makeEnv()- let now = reportTestNow- let project = makeTestProject(name: "Alpha", context: env.context)- // Task is at reportTestNow (Feb 18, 2026), so lastYear (2025) should be empty- makeTerminalTask(name: "Recent task", project: project, completionDate: now, context: env.context) - let markdown = GenerateReportIntent.execute(- dateRange: .lastYear, taskService: env.taskService, milestoneService: env.milestoneService+ let result = GenerateReportIntent.execute(+ dateRange: .lastYear,+ taskService: env.taskService,+ milestoneService: env.milestoneService,+ taskFetcher: FailingTerminalTaskFetcher(),+ now: reportTestNow+ )++ #expect(result == IntentError.internalError(hint: "Failed to fetch terminal tasks").json)+ }++ @Test("Milestone fetch failure returns the exact INTERNAL_ERROR payload")+ func milestoneFetchFailureReturnsInternalError() throws {+ let env = try makeEnv()++ let result = GenerateReportIntent.execute(+ dateRange: .lastYear,+ taskService: env.taskService,+ milestoneService: env.milestoneService,+ taskFetcher: EmptyTerminalTaskFetcher(),+ milestoneFetcher: FailingTerminalMilestoneFetcher(),+ now: reportTestNow+ )++ #expect(result == IntentError.internalError(hint: "Failed to fetch terminal milestones").json)+ }++ @Test("Successful empty fetches preserve exact empty-state Markdown")+ func validEmptyReportPreservesMarkdownAndDateRange() throws {+ let env = try makeEnv()+ let expected = ReportMarkdownFormatter.format(+ ReportLogic.buildReport(tasks: [], milestones: [], dateRange: .lastYear, now: reportTestNow)+ )++ let result = GenerateReportIntent.execute(+ dateRange: .lastYear,+ taskService: env.taskService,+ milestoneService: env.milestoneService,+ taskFetcher: EmptyTerminalTaskFetcher(),+ milestoneFetcher: EmptyTerminalMilestoneFetcher(),+ now: reportTestNow ) - #expect(markdown.contains("# Report: Last Year"))- #expect(markdown.contains("No tasks completed or abandoned in this period."))+ #expect(result == expected) } @Test("All date range cases produce valid output") func allCasesProduceValidOutput() throws { let env = try makeEnv()@@ -82,11 +137,14 @@ struct GenerateReportIntentTests { let lastYear = calendar.date(byAdding: .year, value: -1, to: now)! makeTerminalTask(name: "LastYear", project: project, completionDate: lastYear, context: env.context) for dateRange in ReportDateRange.allCases { let markdown = GenerateReportIntent.execute(- dateRange: dateRange, taskService: env.taskService, milestoneService: env.milestoneService+ dateRange: dateRange,+ taskService: env.taskService,+ milestoneService: env.milestoneService,+ now: reportTestNow ) #expect(!markdown.isEmpty, "Output for \(dateRange.rawValue) should not be empty") #expect(markdown.contains("# Report:"), "Output for \(dateRange.rawValue) should contain report header") } }
diff --git a/specs/bugfixes/generate-report-intent-fetch-failures/report.md b/specs/bugfixes/generate-report-intent-fetch-failures/report.mdnew file mode 100644index 0000000..7073add--- /dev/null+++ b/specs/bugfixes/generate-report-intent-fetch-failures/report.md@@ -0,0 +1,82 @@+# Bugfix Report: Generate Report Intent Fetch Failures++**Date:** 2026-08-04+**Status:** Fixed++## Description of the Issue++`GenerateReportIntent` caught a terminal-task or terminal-milestone storage fetch failure, logged it, and formatted empty report data as though no records matched the requested date range.++**Reproduction steps:**+1. Run report generation with a terminal-task fetcher that throws.+2. Run report generation with a terminal-milestone fetcher that throws after a successful task fetch.+3. Observe normal empty-state Markdown rather than the automation error envelope.++**Impact:** Shortcuts and CLI callers cannot distinguish retryable SwiftData/storage failures from a legitimate empty report, so they can treat an incomplete result as successful automation output.++## Investigation Summary++- **Symptoms examined:** A broad source search found the catch block in `Transit/Transit/Intents/GenerateReportIntent.swift` constructing empty `ReportData` for either fetch failure.+- **Code inspected:** `GenerateReportIntent`, `TaskService.fetchTerminalTasks`, `MilestoneService.fetchTerminalMilestones`, `IntentError`, existing query-fetch-failure regressions, report logic, formatter, and date-range helpers.+- **Hypotheses tested:** Focused tests with deterministic terminal-fetch seams prove task and milestone failures return empty Markdown under the original behavior, while successful empty fetches retain their expected Markdown.++## Discovered Root Cause++The intent used one combined `do`/`catch` for both terminal fetches. Its catch branch mapped every error to a valid empty `ReportData` instance, conflating an unavailable store with a successful empty result.++**Defect type:** Error-handling logic error.++**Why it occurred:**+1. Fetch errors were caught to prevent the App Intent from throwing.+2. The fallback was implemented as an empty report rather than the established JSON error envelope.+3. The public result type is `String`, so both conditions became indistinguishable successful-looking Markdown.+4. No deterministic failing fetch seam existed to pin the distinction in tests.++**Contributing factors:** The UI report uses `@Query`, whereas this background App Intent fetches directly through services; it therefore needs its own storage-failure contract tests.++## Resolution for the Issue++`GenerateReportIntent` now fetches terminal tasks and milestones in independent `do`/`catch` blocks. A task failure returns `IntentError.internalError(hint: "Failed to fetch terminal tasks").json`; a milestone failure returns the equivalent `"Failed to fetch terminal milestones"` envelope. Both retain diagnostic logging. Successful fetches continue through the unchanged `ReportLogic.buildReport` and `ReportMarkdownFormatter.format` path.++Narrow `TerminalTaskFetching` and `TerminalMilestoneFetching` protocols make each source independently injectable. The `now` argument is also injectable for stable date-range/Markdown assertions while defaulting to `.now` for the App Intent’s existing behavior.++## Regression Test++**Test file:** `Transit/TransitTests/GenerateReportIntentTests.swift`+**Test names:** `taskFetchFailureReturnsInternalError`, `milestoneFetchFailureReturnsInternalError`, `validEmptyReportPreservesMarkdownAndDateRange`++**What it verifies:** Task and milestone fetch failures emit their exact `INTERNAL_ERROR` JSON envelopes, while successful empty sources still return the exact existing formatter output for the supplied date range.++**Red command:** `xcodebuild test -project Transit/Transit.xcodeproj -scheme Transit -destination 'platform=macOS' -configuration Debug -derivedDataPath ./DerivedData -only-testing:TransitTests/GenerateReportIntentTests`++**Red result:** The two failure regressions failed; matching-task output, valid-empty output, and all date-range coverage passed.++**Green result:** The same focused command passed after the implementation.++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/Intents/GenerateReportIntent.swift` | Returns source-specific structured errors on terminal fetch failures and preserves the normal successful report path. |+| `Transit/Transit/Intents/Shared/TerminalReportFetching.swift` | Adds narrow terminal task/milestone fetch protocols. |+| `Transit/TransitTests/GenerateReportIntentTests.swift` | Adds isolated task-failure, milestone-failure, and exact valid-empty regressions. |+| `CHANGELOG.md` | Documents the automation contract correction. |++## Verification++**Automated:**+- [x] Focused regression suite is red before the fix.+- [x] Focused regression suite passes after the fix.+- [x] `make test-quick` passes.+- [x] `make lint` passes.+- [x] `make build-ios` passes.+- [ ] `make test` completed with unrelated UI failures in `TransitUITests.testClearAll`, `TransitUITests.testEditViewPreservesTaskMilestone`, and `DataMaintenanceUITests.testDataMaintenanceGoldenPath`.++## Prevention++Read-only automation intents must map storage failures to the project’s structured error contract rather than synthesizing domain-success output. Keep independently injectable task and milestone sources so this boundary remains deterministic.++## Related++- Transit T-1620+- Existing query fetch-failure handling (T-1566)
Fresh GitHub Actions run 30836582905 completed successfully for exact head d55ac5e. Local make lint and make test-quick success evidence was supplied for the same exact head and deliberately reused rather than rerun. The review additionally ran git diff --check successfully.