transit PR #232 head → base b247b36e → 8e5195f commits 3 files 6 touched lines +130 / -4 threads 0 open

PR review: #232 — Fix T-1749 project-existence fetch failures

PR #232 by @ArjenSchwarz · merging T-1749/bugfix-add-task-no-projects-fetch → main · view on GitHub

At a glance

  • Changes the no-selected-project path from a lossy try? read into an explicit throwing boundary, avoiding a false NO_PROJECTS diagnosis when SwiftData is unreadable.
  • Uses the existing injectable ModelFetching seam rather than adding a new abstraction; the single production caller maps its failure to the established visual storage error.
  • Tests prove the three user-visible outcomes stay distinct and that none of their failure paths inserts a task.

Verdict

Ready to merge

No findings. The exact PR range preserves SwiftData project-table fetch failures as INTERNAL_ERROR without changing valid empty-store or stale-selection outcomes. The reviewed Add Task implementation is byte-identical to 81170ce; local macOS tests and strict lint pass, and GitHub's claude-review check succeeded on the exact head.

Author's PR description

Shown verbatim — the markdown the author wrote, unmodified.

@ArjenSchwarz · 2026-08-04
## Summary
- propagate `ProjectService.hasAnyProjects()` fetch failures through its existing injected fetch seam
- return visual `INTERNAL_ERROR` rather than `NO_PROJECTS` when no project is selected and the project table cannot be read
- add a deterministic failing-fetch regression; preserve valid empty-store and stale-selected-project behavior

## Validation
- `make test-quick` ✅
- `make lint` ✅
- focused iOS regression in `ProjectLookupStorageFailureSurfaceTests` ✅ as part of `make test`
- `make test` / `make test-ui` are blocked by reproducible, unrelated simulator UI failures: `TransitUITests.testClearAll`, `TransitUITests.testEditViewPreservesTaskMilestone`, and `DataMaintenanceUITests.testDataMaintenanceGoldenPath`, alongside LLDB-version-store / test-runner launch errors. No unrelated UI changes were made.

See `specs/bugfixes/add-task-no-projects-fetch-failure/report.md` for investigation and evidence.

Commits

Three-level explanation

What changed: The Add Task shortcut now says the database had a problem when it cannot read projects, instead of wrongly saying no projects exist. Why it matters: users get an accurate retry/reopen recovery path.
Flow: ProjectService.hasAnyProjects() delegates to its injected fetcher and throws. AddTaskIntent.execute catches only that no-selection read and converts it to VisualIntentError.storageFailure. A selected project still follows findProject, preserving stale-selection semantics.
Boundary preserved: the change removes a lossy error-to-empty-data conversion at a decision boundary. Byte equality with reviewed 81170ce proves the final Add Task control flow is unchanged from the reviewed implementation; targeted tests establish error classification and transactional non-mutation.

Important changes — detailed

ProjectService: propagate project-table read failures

Transit/Transit/Services/ProjectService.swift

Why it matters. Prevents a storage failure from being silently classified as an empty database.

What to look at. ProjectService.hasAnyProjects() (lines 186–194)

Takeaway. Do not use try? when a storage read determines a user-visible error path; preserve the failure through an existing throwing seam.
Rationale. The existing injected fetcher is already the project-read test seam and keeps the change constrained to one service boundary.

Visual Add Task: map no-selection read failure to INTERNAL_ERROR

Transit/Transit/Intents/Visual/AddTaskIntent.swift

Why it matters. Keeps NO_PROJECTS reserved for a verified empty store and preserves stale-selection handling.

What to look at. AddTaskIntent.execute(), no-selection guard (lines 78–89)

Takeaway. Narrow a do/catch to the operation whose error is being classified so adjacent error paths remain semantically distinct.
Rationale. The selected-project lookup has its own findProject mapping; altering it would conflate T-1749 with stale-selection behavior.

Regression tests: pin all outcome and non-mutation boundaries

Transit/TransitTests/ProjectLookupStorageFailureSurfaceTests.swift

Why it matters. Directly covers the prior false NO_PROJECTS result and verifies no task survives the failed operation.

What to look at. visualAddTaskReportsStorageFailureWhenProjectExistenceFetchFails (lines 123–146)

Takeaway. A failing injected fetcher distinguishes unreadable storage from legitimate empty data deterministically.
Rationale. The test reuses the existing ModelFetching double and sits beside the related selected-project storage-failure regression.

Key decisions

Preserve the existing ModelFetching injection seam Reuse the service's established fetch seam rather than creating a second existence abstraction. This makes failure propagation and testing consistent with other project lookups.
Keep no selection and stale selection separate A missing ProjectEntity requires an existence check; a present but deleted entity must still resolve through findProject and return PROJECT_NOT_FOUND.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +2 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 6538779..53245a4 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -1,49 +1,51 @@ # Changelog  All notable changes to this project will be documented in this file.  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ## [Unreleased]  - T-1816: Regression coverage now directly proves that `query_tasks` validates malformed `status`, `not_status`, `type`, `priority`, `unfinished`, `search`, and `displayId` filters before either a missing milestone name or missing milestone display ID can return a successful no-match `[]`. This pins the validation order introduced by T-1608 / PR #217 without changing its production behavior.+- T-1749: Visual Add Task no longer reports `NO_PROJECTS` when it cannot read the project table while resolving an unselected project. The existence check now propagates its SwiftData error through the existing injectable fetch seam, and the visual intent returns `INTERNAL_ERROR` with the storage-failure guidance. Genuine empty stores still return `NO_PROJECTS`; stale selected projects remain on the separate `PROJECT_NOT_FOUND` / storage-failure path.+ - T-1711: MCP `get_projects` now returns the exact tool error `Failed to fetch milestones: <error>` when a project-scoped milestone fetch fails, rather than a successful partial list that omits milestone metadata. The scoped service fetch preserves its injected storage error; deterministic regressions cover the failure, an empty project's omitted `milestones` field, and correctly scoped milestones for another project. - T-1699: Sync heartbeat fetch failures no longer create duplicate `SyncHeartbeat` singleton rows. `SyncManager` now logs and skips a beat when its singleton fetch fails, then retries on the existing next timer interval; a successful empty fetch still creates the record and a successful existing fetch still updates it. The save remains best-effort and timer lifecycle is unchanged. Deterministic `SyncManagerTests` verify zero insertion, later recovery, and the existing/missing cases. - T-1838: Open report views now refresh relative ranges at the next relevant local calendar boundary and whenever the scene returns active. Report filtering and labels share one captured time/calendar snapshot, so they cannot straddle a boundary; scheduling is DST- and calendar-aware, uses a cancellable one-shot task rather than a polling timer, and leaves App Intent and absolute-range behavior unchanged. - T-1675: Project-scoped milestone-name lookups now preserve the exact SwiftData fetch failure through shared assignment as `INTERNAL_ERROR` (`Failed to look up milestone: <error>`), matching JSON/MCP create, update, and scoped-query adapters. The milestone service's injected fetcher is now available in MCP test setup; deterministic regressions prove no-match and ambiguity remain distinct, cross-project unscoped filtering is untouched, and failed lookups create or mutate nothing. - T-1825: Dashboard milestone filters now live-observe local, MCP, and CloudKit milestone changes, retaining selected Done or Abandoned rows within the active project scope so they remain visible and individually deselectable. Open rows remain first and follow their displayed title order; terminal selections are appended deterministically and deduplicated by UUID. Selected rows expose the native selected accessibility trait once, while inaccessible or deleted selections still leave Clear available. Add Task remains open-only.  - T-1657: Project lookup storage failures now remain distinguishable from missing projects across JSON intents, MCP create/query/milestone tools, and visual Add Task. `QueryMilestonesIntent` no longer returns a successful empty array when its project-name lookup is unreadable, while valid missing/ambiguous names and project-ID precedence retain their prior behavior. Visual Add Task now reports `INTERNAL_ERROR` for a failed project read rather than a stale-selection `PROJECT_NOT_FOUND`; deterministic failing-fetch regressions verify cross-surface parity and no task or milestone insertion. - T-1800: Open task details now observe a task-scoped SwiftData comment query instead of a one-time snapshot, so MCP and local insertions/deletions update both the visible comment list and Share export without reopening the view. The query filters through the CloudKit-compatible optional child relationship and uses creation date plus UUID for stable ordering.  - 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-1628: `make test-quick` and every relevant source-building Makefile target now keep Clang module caches and SwiftPM manifest modules/diagnostics workspace-local. `CLANG_MODULE_CACHE_PATH` is passed as an Xcode build setting, `SWIFTPM_MODULECACHE_OVERRIDE` is exported for manifest compilation, and the cache guard now asserts these supported controls while explicitly preserving the intentional omission of `-clonedSourcePackagesDirPath` and `-packageCachePath` so package resolution continues to work. Xcode recipes also explicitly enable `pipefail`, preventing `xcbeautify` from masking build failures on macOS's bundled GNU Make 3.81. - 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-2037: Add Task observes the selected project's milestones and clears a selection that leaves the open picker options. It also revalidates an optional milestone at the final pre-insertion boundary using both live and fresh committed SwiftData state. A milestone closed in another window/context while display-ID allocation awaits now rejects with a user-facing error before any task insertion; nil milestones, same-project validation, one-save task/milestone creation, and dashboard terminal-filter behavior remain unchanged. Deterministic two-context regressions cover both Done and Abandoned transitions and verify no pending or committed task survives. - 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. - MCP request validation now rejects explicit JSON `id: null` as JSON-RPC `-32600 Invalid Request` under protocol `2025-03-26`, while preserving omitted-ID notifications and valid string/integer IDs across single and batch requests (T-1863). - MCP `tools/call` now returns JSON-RPC `-32602 Invalid Params` for unknown tool names and maintenance tools disabled in Settings (T-1883), while retaining useful messages, omitting disabled tools from `tools/list`, and preserving `-32601 Method Not Found` for unsupported top-level methods. Handler and HTTP-route regressions cover the classification boundaries. - T-1819: `QueryTasksIntent` now rejects reversed absolute `from`/`to` date ranges for both `completionDate` and `lastStatusChangeDate` with `INVALID_INPUT`, matching visual `FindTasksIntent` behavior. One-sided/open bounds, equal same-day inclusive ranges, relative-filter precedence, strict T-1799 calendar-date parsing, and current calendar/time-zone semantics remain unchanged; cross-surface regressions cover both date fields. - T-1799: QueryTasksIntent absolute date filters now require exact ASCII `YYYY-MM-DD` values representing valid dates in the user's current calendar. Local `Calendar.current` parsing, non-lenient formatter round-trip validation, and regressions reject normalized invalid days/months, non-padded values, and non-ASCII input while preserving inclusive ranges and relative filters. - MCP Streamable HTTP now returns `405 Method Not Allowed` with `Allow: POST` for `GET /mcp` when no SSE listening stream is offered, while preserving POST handling, origin validation, and unrelated-route 404 behavior (T-1835). - MCP `query_tasks` now verifies that a well-formed `projectId` identifies an existing project before filtering (T-1783). Nonexistent project IDs return the established project-not-found error, while malformed UUID validation and other filters remain unchanged; regression coverage also checks parity with `QueryTasksIntent`. - `AddTaskIntent` now distinguishes an empty-database/no-selection path (`NO_PROJECTS`) from a stale selected `ProjectEntity` (`PROJECT_NOT_FOUND`) even when the deleted project was the last project in the store (T-1814). The required App Intents project parameter contract is preserved; missing project input against a non-empty store returns `INVALID_INPUT`. Entity resolution happens before task creation, and focused regressions assert exact error cases and no task mutation. - MCP `create_task`, `create_milestone`, `CreateTaskIntent`, and `CreateMilestoneIntent` now distinguish missing required fields from present non-string `name`/`type` values (T-1598). Numeric, boolean, array, object, and null inputs return field-specific `name must be a string` / `type must be a string` validation errors before mutation, while missing-field and invalid-string-type behavior remains unchanged. Symmetric regression coverage verifies all four create paths and confirms malformed requests create no records. - `QueryTasksIntent` now rejects explicit JSON `null` in nested `completionDate` and `lastStatusChangeDate` fields (`relative`, `from`, and `to`) before Codable decoding can erase presence. Malformed nested date-filter shapes retain the established `INVALID_INPUT` contract, omitted fields remain unchanged, and regressions cover both filters and no-broadening behavior (T-1644). - `Color.hexString` now rounds resolved RGB components to the nearest byte after finite-checking and clamping instead of truncating values just below byte boundaries (T-1807). Project colors no longer darken when an editor saves an unchanged color. Regression coverage exercises every `0...255` RGB value, deterministic sRGB resolved components immediately below each boundary, and the existing uppercase six-digit RGB/no-alpha format. - T-1802: Compact-width portrait dashboard layouts now always use the segmented single-column view, including widths at and above 400 points. Landscape, iPad, and Mac continue to use geometry-based column adaptation through a focused layout-selection helper and regression coverage. - MCP `update_task_status` now returns the exact comment created by its atomic status-plus-comment mutation (T-1823). `TaskService.updateStatus` propagates the created `Comment` to the handler for direct serialization instead of refetching every task comment and taking the last `creationDate`; a future-dated existing or concurrently imported comment can no longer replace the mutation result. If the atomic save fails, the newly inserted comment is deleted before the task state is rolled back so a later save cannot resurrect it. Regressions verify exact UUID propagation, future-clock-skew behavior, and service/MCP failure recovery. - Cross-device task and milestone promotion now re-probes committed SwiftData state after display-ID allocation and skips assignment when a peer has already supplied a permanent ID (T-2020). Missing or unreadable records fail closed, save-failure recovery preserves peer-merged values, and deterministic two-context tests pin both record types. Because SwiftData has no field-level compare-and-set, this closes the peer-merge-during-await window but cannot prevent two devices whose final local probes both precede either CloudKit merge from racing; the limitation and owner-reservation alternative are documented.  - MCP `initialize` now requires object params with string `protocolVersion`, object `capabilities`, and typed `clientInfo` identity fields (T-1778). Missing or malformed method parameters return JSON-RPC `-32602 Invalid Params`. JSON-RPC request decoding now rejects scalar/null top-level `params` server-wide with `-32600 Invalid Request`. Supported `2025-03-26` requests are echoed and unsupported versions receive the server's latest advertised supported version. Focused handshake tests cover absent params/fields, wrong types, malformed client information, request-shape errors, and both negotiation paths. - MCP server start, stop, and same-port restart now run through one async desired-state lifecycle coordinator (T-1826). Each active listener owns its Hummingbird `ServiceGroup` and run task; teardown explicitly triggers graceful shutdown and awaits the group before rebinding, rather than assuming cancellation completion releases the socket. Graceful shutdown is time-bounded so a stalled request cannot wedge the coordinator, and the group installs no SIGTERM/SIGINT traps (`runService()`'s default) because those change the process's signal disposition permanently. Requests arriving during teardown replace the pending target so a burst converges on the latest state, Settings uses an explicit restart operation, and live loopback regressions cover stop, 20 serialized same-port repetitions, different-port restart, rapid off/on, an occupied port surfacing the bind failure, an invalid port releasing the running listener, bounded shutdown escalation, empty signal configuration, and reusable bind-probe behavior. - Already-cancelled task and milestone creates now stop before an uncontended display-ID allocation, and cancellation during a successful non-cooperative allocation is re-checked before SwiftData insertion (T-1765). `AllocationGate` checks cancellation once it holds the lock and before running the allocation body, while both create services guard their persistence boundary; existing selective `insertOrDelete` save-failure cleanup is unchanged. The gate check is shared by every allocator caller, so a cancelled display-ID promotion or duplicate-cleanup pass now stops at the current record and retries on the next pass. If the counter advance already succeeded, cancellation may leave a harmless gap in the display-ID sequence rather than persisting a ghost record. Four deterministic regressions cover the new timing windows across tasks and milestones, and the two retained queued-cancellation cases now prove that their contender entered and was removed from the gate queue. - The MCP Streamable HTTP endpoint now accepts non-empty JSON-RPC request/notification batches for advertised protocol `2025-03-26` (T-1834). It decodes each member independently, dispatches requests and notifications sequentially, omits notification responses, preserves response-array semantics, returns HTTP 202 with no body for all-notification batches, emits per-member `-32600` errors for invalid entries, handles empty batches as one invalid-request object, and rejects lifecycle-invalid batched `initialize` calls. Route-level regression tests cover each behavior and unchanged single-request responses.
Transit/Transit/Intents/Visual/AddTaskIntent.swift Modified +7 / -1
diff --git a/Transit/Transit/Intents/Visual/AddTaskIntent.swift b/Transit/Transit/Intents/Visual/AddTaskIntent.swiftindex 7b3d353..dad1b4f 100644--- a/Transit/Transit/Intents/Visual/AddTaskIntent.swift+++ b/Transit/Transit/Intents/Visual/AddTaskIntent.swift@@ -39,75 +39,81 @@ struct AddTaskIntent: AppIntent {      @Dependency     private var projectService: ProjectService      @MainActor     func perform() async throws -> some ReturnsValue<TaskCreationResult> {         let result = try await Self.execute(             name: name,             taskDescription: taskDescription,             type: type,             project: project,             services: Services(taskService: taskService, projectService: projectService)         )         return .result(value: result)     }      // MARK: - Logic (testable without @Dependency)      @MainActor     static func execute(         name: String,         taskDescription: String?,         type: TaskType,         project: ProjectEntity?,         services: Services,         persistence: PersistenceAvailability = .shared     ) async throws -> TaskCreationResult {         // Refuse to write while the in-memory fallback container is active [T-1836].         guard !persistence.isFallbackStorageActive else {             throw VisualIntentError.persistenceUnavailable         }          let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)         guard !trimmedName.isEmpty else {             throw VisualIntentError.invalidInput("Name is required.")         }          // Keep no selection distinct from a stale entity so the latter can report         // PROJECT_NOT_FOUND even when the deleted project was the last one.         guard let project else {-            if services.projectService.hasAnyProjects() {+            let hasProjects: Bool+            do {+                hasProjects = try services.projectService.hasAnyProjects()+            } catch {+                throw VisualIntentError.storageFailure("Failed to fetch projects: \(error)")+            }+            if hasProjects {                 throw VisualIntentError.invalidInput("Project is required.")             }             throw VisualIntentError.noProjects         }          let resolvedProject: Project         switch services.projectService.findProject(id: project.projectId) {         case .success(let existingProject):             resolvedProject = existingProject         case .failure(.storageFailure(let hint)):             throw VisualIntentError.storageFailure(hint)         case .failure:             throw VisualIntentError.projectNotFound(                 "Selected project no longer exists."             )         }          let task: TransitTask         do {             task = try await services.taskService.createTask(                 name: trimmedName,                 description: taskDescription,                 type: type,                 project: resolvedProject             )         } catch TaskService.Error.invalidName {             throw VisualIntentError.invalidInput("Name is required.")         } catch {             throw VisualIntentError.taskCreationFailed("Unable to create task.")         }          return try TaskCreationResult.from(task)     } }
Transit/Transit/Services/ProjectService.swift Modified +5 / -2
diff --git a/Transit/Transit/Services/ProjectService.swift b/Transit/Transit/Services/ProjectService.swiftindex b3d2736..88eb37e 100644--- a/Transit/Transit/Services/ProjectService.swift+++ b/Transit/Transit/Services/ProjectService.swift@@ -147,46 +147,49 @@ final class ProjectService {     /// Checks whether a project with the given name already exists (case-insensitive).     ///     /// When `excluding` is provided, the project with that ID is ignored — this     /// supports the rename scenario where the project's own current name should     /// not count as a conflict.     ///     /// Throws the underlying storage error when the projects cannot be read.     /// CloudKit-backed SwiftData forbids `@Attribute(.unique)`, so this check *is*     /// the uniqueness invariant — reporting `false` for an unreadable store would     /// let create/rename commit a duplicate name with nothing underneath to stop     /// it (T-1614).     func projectNameExists(_ name: String, excluding projectId: UUID? = nil) throws -> Bool {         let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)         let allProjects = try fetcher.fetch(FetchDescriptor<Project>())         return allProjects.contains { project in             if let projectId, project.id == projectId { return false }             return project.name.localizedCaseInsensitiveCompare(trimmed) == .orderedSame         }     }      // MARK: - Queries      /// Returns the number of tasks in non-terminal statuses for a project.     func activeTaskCount(for project: Project) -> Int {         let tasks = project.tasks ?? []         return tasks.filter { !$0.status.isTerminal }.count     }      /// Fetches all projects, optionally sorted by name.     func fetchAllProjects(sortedByName: Bool = false) throws -> [Project] {         let descriptor: FetchDescriptor<Project>         if sortedByName {             descriptor = FetchDescriptor<Project>(sortBy: [SortDescriptor(\Project.name)])         } else {             descriptor = FetchDescriptor<Project>()         }         return try modelContext.fetch(descriptor)     }      /// Returns true if at least one project exists.-    func hasAnyProjects() -> Bool {+    ///+    /// Throws the underlying storage error rather than treating an unreadable+    /// project table as an empty database.+    func hasAnyProjects() throws -> Bool {         var descriptor = FetchDescriptor<Project>()         descriptor.fetchLimit = 1-        return ((try? modelContext.fetch(descriptor)) ?? []).isEmpty == false+        return try fetcher.fetch(descriptor).isEmpty == false     } }
Transit/TransitTests/AddTaskIntentTests.swift Modified +1 / -1
diff --git a/Transit/TransitTests/AddTaskIntentTests.swift b/Transit/TransitTests/AddTaskIntentTests.swiftindex 336767a..5ac7134 100644--- a/Transit/TransitTests/AddTaskIntentTests.swift+++ b/Transit/TransitTests/AddTaskIntentTests.swift@@ -168,59 +168,59 @@ struct AddTaskIntentTests {                 services: AddTaskIntent.Services(taskService: svc.task, projectService: svc.project)             )             Issue.record("Expected projectNotFound error")         } catch let error as VisualIntentError {             #expect(error == .projectNotFound("Selected project no longer exists."))         }          let tasks = try svc.context.fetch(FetchDescriptor<TransitTask>())         #expect(tasks.isEmpty)     }      @Test func executeThrowsInvalidInputForMissingProjectWhenProjectsExist() async throws {         let svc = try makeServices()         _ = makeProject(in: svc.context)          do {             _ = try await AddTaskIntent.execute(                 name: "Task",                 taskDescription: nil,                 type: .feature,                 project: nil,                 services: AddTaskIntent.Services(taskService: svc.task, projectService: svc.project)             )             Issue.record("Expected invalidInput error")         } catch let error as VisualIntentError {             #expect(error == .invalidInput("Project is required."))         }          let tasks = try svc.context.fetch(FetchDescriptor<TransitTask>())         #expect(tasks.isEmpty)     }      @Test func executeThrowsProjectNotFoundForStaleProjectSelectionWhenAnotherProjectRemains() async throws {         let svc = try makeServices()         let deletedProject = makeProject(in: svc.context, name: "Deleted Project")         _ = makeProject(in: svc.context, name: "Remaining Project")         let selectedProject = makeEntity(from: deletedProject)         svc.context.delete(deletedProject)         try svc.context.save() -        #expect(svc.project.hasAnyProjects())+        #expect(try svc.project.hasAnyProjects())          do {             _ = try await AddTaskIntent.execute(                 name: "Task",                 taskDescription: nil,                 type: .feature,                 project: selectedProject,                 services: AddTaskIntent.Services(taskService: svc.task, projectService: svc.project)             )             Issue.record("Expected projectNotFound error")         } catch let error as VisualIntentError {             #expect(error == .projectNotFound("Selected project no longer exists."))         }          let tasks = try svc.context.fetch(FetchDescriptor<TransitTask>())         #expect(tasks.isEmpty)     } }
Transit/TransitTests/ProjectLookupStorageFailureSurfaceTests.swift Modified +25 / -0
diff --git a/Transit/TransitTests/ProjectLookupStorageFailureSurfaceTests.swift b/Transit/TransitTests/ProjectLookupStorageFailureSurfaceTests.swiftindex 0f25a4d..96e21e1 100644--- a/Transit/TransitTests/ProjectLookupStorageFailureSurfaceTests.swift+++ b/Transit/TransitTests/ProjectLookupStorageFailureSurfaceTests.swift@@ -99,41 +99,66 @@ struct ProjectLookupStorageFailureSurfaceTests {             arguments: ["project": "Transit"]         ))          for response in [createTask, queryTasks, createMilestone, queryMilestones] {             #expect(try MCPTestHelpers.isError(response))         }         let idLookupFailure = "Failed to fetch project: simulated project fetch failure"         let nameLookupFailure = "Failed to fetch projects: simulated project fetch failure"         #expect(try MCPTestHelpers.errorText(createTask) == idLookupFailure)         #expect(try MCPTestHelpers.errorText(queryTasks) == idLookupFailure)         #expect(try MCPTestHelpers.errorText(createMilestone) == idLookupFailure)         #expect(try MCPTestHelpers.errorText(queryMilestones) == nameLookupFailure)         #expect(try env.context.fetch(FetchDescriptor<TransitTask>()).isEmpty)         #expect(try env.context.fetch(FetchDescriptor<Milestone>()).isEmpty)     } #endif      @Test func visualAddTaskReportsInternalStorageErrorWithoutInsertionWhenProjectLookupFails() async throws {         let testContainer = try TestModelContainer()         let context = testContainer.context         let taskService = TaskService(modelContext: context, displayIDAllocator: allocator())         let projectService = ProjectService(modelContext: context, fetcher: FailingProjectFetcher())         let project = ProjectEntity(id: UUID().uuidString, projectId: UUID(), name: "Transit")          do {             _ = try await AddTaskIntent.execute(                 name: "Task",                 taskDescription: nil,                 type: .feature,                 project: project,                 services: AddTaskIntent.Services(taskService: taskService, projectService: projectService)             )             Issue.record("Expected project storage failure")         } catch let error as VisualIntentError {             #expect(error.code == "INTERNAL_ERROR")             #expect(error.errorDescription == "Storage error: Failed to fetch project: simulated project fetch failure")         }          #expect(try context.fetch(FetchDescriptor<TransitTask>()).isEmpty)     }++    // T-1749: an unreadable project table must remain a storage error even when+    // Shortcuts has no selected project. It must not be reported as NO_PROJECTS.+    @Test func visualAddTaskReportsStorageFailureWhenProjectExistenceFetchFails() async throws {+        let testContainer = try TestModelContainer()+        let context = testContainer.context+        let taskService = TaskService(modelContext: context, displayIDAllocator: allocator())+        let projectService = ProjectService(modelContext: context, fetcher: FailingProjectFetcher())++        do {+            _ = try await AddTaskIntent.execute(+                name: "Task",+                taskDescription: nil,+                type: .feature,+                project: nil,+                services: AddTaskIntent.Services(taskService: taskService, projectService: projectService)+            )+            Issue.record("Expected project existence storage failure")+        } catch let error as VisualIntentError {+            #expect(error == .storageFailure("Failed to fetch projects: simulated project fetch failure"))+            #expect(error.code == "INTERNAL_ERROR")+        }++        #expect(try context.fetch(FetchDescriptor<TransitTask>()).isEmpty)+    } }
specs/bugfixes/add-task-no-projects-fetch-failure/report.md Added +90 / -0
diff --git a/specs/bugfixes/add-task-no-projects-fetch-failure/report.md b/specs/bugfixes/add-task-no-projects-fetch-failure/report.mdnew file mode 100644index 0000000..8945421--- /dev/null+++ b/specs/bugfixes/add-task-no-projects-fetch-failure/report.md@@ -0,0 +1,90 @@+# Bugfix Report: Add Task No-Projects Fetch Failure++**Date:** 2026-08-05+**Status:** Fixed++## Description of the Issue++When the visual Add Task shortcut has no selected project, it asks `ProjectService.hasAnyProjects()` whether setup is empty. A SwiftData project-table fetch failure is silently converted to `false`, so the shortcut reports `NO_PROJECTS` and tells the user to create a project even though storage could be unavailable.++**Reproduction steps:**+1. Inject a project fetcher that throws a deterministic storage error.+2. Run `AddTaskIntent.execute` with no selected project.+3. Observe that the pre-fix implementation returns `VisualIntentError.noProjects` rather than an internal storage error.++**Impact:** A retryable data-access failure is misclassified as a setup problem, sending Shortcuts users toward the wrong recovery action.++## Investigation Summary++- **Symptoms examined:** T-1749’s no-selection path; direct regression fails on the merged baseline.+- **Code inspected:** `ProjectService.hasAnyProjects()`, `AddTaskIntent.execute`, and T-1657’s `ProjectLookupStorageFailureSurfaceTests`.+- **Hypotheses tested:** T-1657/#224 correctly maps a storage failure for a selected `ProjectEntity`; it does not cover or alter the separate no-selection existence fetch. That selected-project path is T-2078’s stale-selection context and remains out of scope.++## Discovered Root Cause++`ProjectService.hasAnyProjects()` wraps `modelContext.fetch` in `try?`, replacing a failed fetch with an empty array. `AddTaskIntent` treats the resulting `false` as evidence that no projects exist.++**Defect type:** Silent error handling / incorrect error classification.++**Why it occurred:**+1. The existence check must fetch the project table.+2. The fetch was made non-throwing with `try?`.+3. Failure became an empty result.+4. Empty result selected the `NO_PROJECTS` branch.+5. The visual intent had no opportunity to distinguish storage failure from a genuine empty store.++**Contributing factors:** The existing injectable project fetch seam was used by lookup operations but not by the existence check, so the no-selection path was not regression-tested.++## Resolution for the Issue++**Changes made:**+- `Transit/Transit/Services/ProjectService.swift` - Made `hasAnyProjects()` throw and read through the existing injected `ModelFetching` seam, preserving the underlying storage failure.+- `Transit/Transit/Intents/Visual/AddTaskIntent.swift` - Maps an unselected-project existence-read failure to `VisualIntentError.storageFailure` (`INTERNAL_ERROR`) instead of `NO_PROJECTS`.+- `Transit/TransitTests/ProjectLookupStorageFailureSurfaceTests.swift` - Adds the exact no-selection, failing-project-table regression alongside T-1657’s selected-project storage regression.++**Approach rationale:** The existing `fetcher` dependency already makes project reads deterministically testable. Exposing the real error at the existing boolean boundary is smaller and safer than adding a second existence abstraction or changing the established visual error type.++**Alternatives considered:**+- Treat a failed fetch as an empty project list - rejected because it recreates the incorrect `NO_PROJECTS` diagnosis.+- Reuse the selected-project lookup result - not applicable because no `ProjectEntity` exists in this path; that is T-2078’s separate stale-selection concern.++## Regression Test++**Test file:** `Transit/TransitTests/ProjectLookupStorageFailureSurfaceTests.swift`+**Test name:** `visualAddTaskReportsStorageFailureWhenProjectExistenceFetchFails`++**What it verifies:** An injected project-table fetch failure with no selected project produces `VisualIntentError.storageFailure` / `INTERNAL_ERROR`, not `NO_PROJECTS`, and creates no task.++**Run command:** `make test-quick`++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/Services/ProjectService.swift` | Exposes project-table fetch failure through `hasAnyProjects()`. |+| `Transit/Transit/Intents/Visual/AddTaskIntent.swift` | Maps the existence failure to the established visual storage error. |+| `Transit/TransitTests/ProjectLookupStorageFailureSurfaceTests.swift` | Adds the exact visual no-selection failure regression. |+| `Transit/TransitTests/AddTaskIntentTests.swift` | Updates the existing successful existence assertion for the throwing API. |++## Verification++**Automated:**+- [x] Regression fails before the fix+- [x] Regression test passes+- [x] Full macOS unit suite passes (`make test-quick`)+- [x] Linters/validators pass (`make lint`)++**Manual verification:** The injected failing-fetcher test exercises the same visual intent path that Shortcuts invokes.++**UI-suite note:** `make test-ui` was attempted and then the three failed scenarios were rerun directly. `TransitUITests.testClearAll`, `TransitUITests.testEditViewPreservesTaskMilestone`, and `DataMaintenanceUITests.testDataMaintenanceGoldenPath` all fail while the iOS simulator reports repeated LLDB-version-store and test-runner launch failures (`RequestDenied` / server died). These UI scenarios are outside T-1749’s project-existence error path; no unrelated UI changes were made.++## Prevention++- Do not use `try?` to turn storage reads that drive user-visible decisions into empty data.+- Route all project reads covered by `ProjectService` through its injectable fetch seam when tests must distinguish failures from empty results.++## Related++- T-1749+- T-1657 / PR #224 (selected-project lookup failure handling)+- T-2078 (separate selected stale-project storage-failure mapping)

Things to double-check

Exact reviewed implementation git diff --exit-code 81170ce b247b36e -- Transit/Transit/Intents/Visual/AddTaskIntent.swift returned clean: the final file is byte-identical to the reviewed commit.
Validation scope make test-quick and make lint both passed locally. The GitHub claude-review check completed successfully for head b247b36e; GraphQL returned zero PR review threads.