Skip to content

v1.8.0 - Regression-suite bug-fix release

Choose a tag to compare

@ebowman ebowman released this 22 Aug 02:07
· 89 commits to main since this release

Fixed

Scheduling (when=)

  • when='today' (and an explicit ISO date equal to today) now schedules into the Anytime list, not an "unconfirmed" Someday state, and is visible in get_anytime (bead hq-x9z). add_todo, update_todo, bulk_update_todos, add_project, and update_project share the same AppleScript scheduler (scheduling/strategies.py's SchedulingStrategies), which previously scheduled when='today' via schedule theTodo for (current date) - Things' own "schedule" AppleScript verb, confirmed live, always leaves the to-do in its unconfirmed/yellow-dot state (things.get() reports start='Someday', start_date=today), which shows up in things.today()/get_today (whose own predicate explicitly includes that state) but not things.anytime()/get_anytime - unlike the Things URL-scheme when='today' path (checklist/heading/evening adds), Things' own "Today" UI button, or the equivalent move_record(destination_list='today'), all of which yield start='Anytime'. The scheduler now issues move theTodo to list "Today" instead of the schedule verb whenever the resolved date is today (confirmed live via things.get() reporting start='Anytime', start_date=today, and membership in both things.today() and things.anytime()); schedule is unchanged for any other date (e.g. 'tomorrow' or a future ISO date), where Things' own start='Someday' + future start_date representation is normal and expected (things.upcoming() explicitly keys off exactly that state).
  • when='anytime' now schedules into the Anytime list, not Someday (bead hq-z5d). add_todo, update_todo, bulk_update_todos, add_project, and update_project all share the same AppleScript scheduling fallback (scheduling/helpers.py's determine_target_list), which previously mapped the literal string 'anytime' to the same "Someday" list-move target as 'someday' - a to-do/project scheduled with when='anytime' ended up with start='Someday', indistinguishable from a native when='someday' item. It now maps to "Anytime", matching CLAUDE.md's documented behavior and Things' own Anytime list (move theTodo to list "Anytime", confirmed live via things.get() reporting start='Anytime', start_date=None).
  • when='YYYY-MM-DD@HH:MM' now actually sets a reminder (bead hq-4gn). add_todo, update_todo, bulk_update_todos, add_project, and update_project all accepted this date+time form (ParameterValidator.validate_date_format allows the @HH:MM suffix), but the AppleScript scheduling path (SchedulingStrategies.schedule_todo_reliable -> locale_aware_dates.normalize_date_input) only ever extracted year/month/day, so the time component was silently dropped and no reminder was ever set. when values with a @HH:MM component are now routed through the Things URL scheme instead (which supports this form natively and sets reminder_time), the same way when='evening' already was: add_todo via things:///add (no auth token required); update_todo/bulk_update_todos via things:///update (requires the Things auth token, checked before any AppleScript write so a missing token never partially applies other fields in the same call). Unlike evening, this form IS supported for projects (live-verified against things:///add-project/things:///update-project, both of which set a project reminder natively) - add_project/update_project route it via things:///update-project (also auth-token-gated). Also fixes a related validation gap: ParameterValidator.validate_date_format previously accepted any @H:MM/@HH:MM-shaped string without validating the hour/minute ranges (e.g. '2026-01-01@25:99' passed validation) - it now rejects out-of-range times with INVALID_WHEN, the same as any other malformed when.

Move destinations

  • move_record/bulk_move_records now reject destination_list='upcoming' at validation instead of accepting it and later failing with APPLESCRIPT_ERROR (bead hq-cag). Things has no direct "Upcoming" move target - an item is Upcoming by having a future start date, and Things' own AppleScript move verb rejects move ... to list "upcoming" with "Cannot move to-do". Rather than silently guessing an arbitrary future date, 'upcoming' is no longer accepted as a destination: move_record returns VALIDATION_ERROR and bulk_move_records returns INVALID_DESTINATION (validated once up front, before any per-todo move is attempted, so nothing in the batch moves), both with a message pointing callers at update_todo(id=..., when='<YYYY-MM-DD>') (or when='tomorrow') to schedule the to-do for a future date instead.
  • move_record/bulk_move_records now actually route destination_list='logbook'/'trash' instead of always returning INVALID_DESTINATION (bead hq-edj). MoveOperationsTools._validate_destination already accepted 'logbook' and 'trash' in its valid_lists, but _execute_move's destination-routing if/elif chain only recognized ['inbox', 'today', 'upcoming', 'anytime', 'someday'] as built-in list moves - 'logbook' and 'trash' fell through to the else branch and returned INVALID_DESTINATION even though validation had accepted them (both move_record and bulk_move_records share this code path, so both were affected). 'trash' now uses the same move theTodo to list "trash" verb already used for the other built-in lists (live-verified). 'logbook' has no direct move target in Things' AppleScript dictionary - the only documented way an item reaches the Logbook is completion - so it now issues set status of theTodo to completed (live-verified: reports status='completed' and membership in things.logbook()).

Structured envelope / response contract

  • BREAKING: mode='summary' preview rows (recent_preview/recent_projects/result_preview) now emit the documented SUMMARY field set instead of {id, name} (bead hq-9tm). get_todos/get_today/get_inbox/get_upcoming/get_anytime/get_someday/get_projects/search_todos/search_advanced under mode='summary' build their preview items via ProgressiveDisclosureEngine._summarize_todos/_summarize_projects/_summarize_search_results, which previously hand-built each preview row as {"id": <uuid>, "name": <title>[:50]} - contradicting CLAUDE.md's "Todo field lists per mode" SUMMARY contract of {uuid, title, status, tags, dueDate} (and the parallel PROJECT_FIELD_SETS entry, which is identical). Preview rows now go through the same field-filtering convention as every other mode: each row is built from TODO_FIELD_SETS[SUMMARY]/PROJECT_FIELD_SETS[SUMMARY] (dispatched per-row by the item's own type, matching _apply_field_filtering's existing per-row logic), so {uuid, title, status, tags, dueDate} are present when non-null on the source item and absent otherwise (fields are never invented) - id/name no longer appear at all. Any caller reading id/name off a summary-mode preview row must switch to uuid/title.
  • List tools no longer silently drop items via relevance-ranked pagination when the ~80KB response-size budget is exceeded (bead hq-cal.2). ContextAwareResponseManager.optimize_response (context_manager.py) enforced the budget by re-ranking items with a relevance heuristic and returning only the top-scoring subset - this fired even when the caller passed no limit, and total still reported the full pre-truncation count, so a freshly created but low-relevance item (e.g. a new project on a large Anytime list) could be unreachable via that list tool even though it appeared to have been requested in full (list tools like get_anytime/get_today/get_upcoming/get_someday have no offset). _handle_oversized_response now keeps a deterministic prefix of the list in its original order (the order the tool produced it in, i.e. things.py order) instead of relevance re-ranking, and the structured envelope now carries an explicit truncated: true + truncation_hint (a short string explaining how to reach the rest, e.g. via a smaller mode/limit/more specific filter) whenever this budget truncation fires - both keys are absent (not false) when it doesn't, so untruncated envelopes remain byte-stable. This propagates through ThingsMCPServer._read_result to structured_content for every list tool that routes through optimize_response (get_today/get_inbox/get_upcoming/get_anytime/get_someday/get_todos/get_projects/etc.). See CLAUDE.md's Structured Output section for the full contract.
  • search_todos(mode='summary')/search_advanced(mode='summary') now return a non-empty items preview instead of always items=[] (bead hq-cal.4). ThingsMCPServer._read_result's summary-preview fallback checked recent_preview/recent_projects/tags/top (the preview keys used by the todo/project/tag summarizers) but never result_preview, the key ProgressiveDisclosureEngine._summarize_search_results actually populates - so search summaries always resolved to an empty preview list even though count/total were already correct. result_preview is now included in the fallback chain.
  • get_logbook/get_due_in_days/get_activating_in_days/get_tags/get_tagged_items/get_recent now carry requested_mode in structured_content, matching CLAUDE.md's documented uniform envelope contract (bead hq-lsb). ThingsMCPServer._read_result's list branch (taken by these six tools, which each pass a raw list rather than the {"data": ..., "meta": ...} dict shape context_manager.optimize_response produces) only ever set items/count/total/mode/limit/offset, never requested_mode - the key was silently absent from their structured_content, unlike every other list-returning read tool. None of these six tools has a mode parameter at all, so requested_mode now reports None (nothing was requested) while mode continues to report the effective/concrete shape ('standard') of the returned items - the same mode-vs-requested_mode distinction already documented for tools that do accept mode.
  • get_trash now carries requested_mode: None in structured_content, matching the other no-mode-parameter list tools (bead hq-cal.3). get_trash has no mode parameter, but routed through ThingsMCPServer._read_result's dict branch (server.py) without passing requested_mode=None explicitly, so it fell through to echoing mode='standard' back as requested_mode too - unlike get_logbook/get_due_in_days/get_activating_in_days/get_tags/get_tagged_items/get_recent, which were already fixed to report requested_mode: None by hq-lsb. get_trash's call site now passes requested_mode=None explicitly; mode continues to report the effective/concrete shape ('standard'). Audited every other _read_result call site in server.py - no other dict- or list-branch caller has this defect.
  • get_inbox/get_today/get_upcoming/get_anytime/get_someday now return the canonical structured invalid_mode error for a bogus mode instead of crashing with an opaque ToolError (bead hq-exd). These five list tools passed mode straight into ResponseMode(...) unguarded - a bogus mode string (e.g. mode='bogus') raised an unhandled ValueError, surfaced by FastMCP as a ToolError with no structured_content, unlike get_todos/get_projects/get_areas/get_project_headings/search_todos/search_advanced, which already returned read_error('invalid_mode', ...). The inline validation duplicated across those six tools is now a single shared ThingsMCPServer._validate_mode helper, applied uniformly to all eleven mode-accepting read tools so the check and error shape can never drift apart again.

Tag handling

  • bulk_update_todos(tags=...) no longer double-emits newly-created tags in the AppleScript tag string under the allow_all tag policy (bead hq-3bp). TagValidationResult.valid_tags already includes created_tags under ALLOW_ALL (TagValidationService._apply_policy extends valid_tags with the tags it auto-creates), but bulk_operations.py::_validate_bulk_params concatenated tag_validation['existing'] (mapped from valid_tags) with tag_validation['created'] again at the call site - so bulk_update_todos(tags='a,b') under ALLOW_ALL emitted 'a, b, a, b' to AppleScript instead of 'a, b' (Things itself deduplicates the applied tags, so the visible end-state was correct, but the emitted payload was wrong and wasteful). Fixed by using tag_validation['existing'] alone, which is already the complete, correctly-deduplicated set under every policy (ALLOW_ALL/FILTER_SILENT/FILTER_WARN/FAIL_ON_UNKNOWN - created_tags is always empty under the three filtering policies, so this is a no-op change there). The identical pattern existed in write_operations.py::_prepare_tags (shared by add_todo/update_todo/add_project/update_project/add_area/update_area/add_tags) but was masked there by a dict.fromkeys() dedup already applied to its result before use - fixed the same way for consistency, with no behavior change to its callers.
  • THINGS_MCP_TAG_CREATION_POLICY (env var / env_file) is no longer a dead configuration knob - filter_silent and fail_on_unknown are now reachable (bead hq-nb1). config.py's tag_creation_policy and ai_can_create_tags fields were cross-synced via a pair of field_validator(mode='before') validators whose order was determined by field-declaration order, not by which value the caller actually set: ai_can_create_tags (declared first) was always validated before tag_creation_policy, so tag_creation_policy's validator always found ai_can_create_tags already present and unconditionally derived the policy from that field alone, discarding whatever tag_creation_policy had been explicitly set to. As a result THINGS_MCP_TAG_CREATION_POLICY=filter_silent/fail_on_unknown (env var or env_file) silently collapsed to ALLOW_ALL/FILTER_WARN depending only on ai_can_create_tags, and the same collapse applied to constructing ThingsMCPConfig(tag_creation_policy=...) directly. Both field_validators are replaced with a single model_validator(mode='after') that reconciles the two fields using model_fields_set to detect which one(s) were actually provided (env var, env_file, or constructor kwarg) versus left at their defaults: an explicitly-set tag_creation_policy now always wins and is never silently overridden; ai_can_create_tags alone (policy unset) still derives the policy as before (True -> ALLOW_ALL, False -> FILTER_WARN); when both are explicitly set and conflict, tag_creation_policy wins and ai_can_create_tags is recomputed to match (policy == ALLOW_ALL <-> True), with a logged warning noting the conflict. All four policies (allow_all/filter_silent/filter_warn/fail_on_unknown) are now reachable via env var, env_file, and constructor kwargs alike. The declared default for tag_creation_policy is also corrected from fail_on_unknown to filter_warn in this same change: the pre-fix validator bug meant an unconfigured tag_creation_policy never actually resolved to its then-declared FAIL_ON_UNKNOWN default (it was always silently rewritten to FILTER_WARN, derived from ai_can_create_tags's default of False) - so every unconfigured deployment has always behaved as filter_warn in practice, matching CLAUDE.md's documented "Non-existent tags are silently filtered (no error)". Correcting the declared default to filter_warn means there is no actual behavior change for unconfigured users from this fix - only the previously-dead THINGS_MCP_TAG_CREATION_POLICY env var and explicit-conflict reconciliation are new.
  • create_tag no longer creates a blank-titled tag from a whitespace-only name (bead hq-r87). create_tag(' ') previously reached AppleScript unchanged - Things silently trimmed the whitespace and created a real tag with an empty title, unlike create_tag(''), which was already rejected with TAG_CREATION_FAILED at the AppleScript layer. create_tag now strips and rejects a whitespace-only tag_name with the same TAG_CREATION_FAILED error before any AppleScript call is made (checked after the ai_can_create_tags/TAG_CREATION_RESTRICTED gate, matching the existing empty-name check's position). add_tags's MCP-boundary tag parsing (server.py's _parse_tag_list) already filters whitespace-only tokens out entirely (if t.strip() per token) before they ever reach tag validation/AppleScript - reviewed and confirmed no separate fix was needed there; a whitespace-only add_tags(tags=...) request resolves to the pre-existing NO_VALID_TAGS error, not a blank tag.

Validation / write-safety pre-checks

  • update_todo/bulk_update_todos now pre-check the primary todo_id via things.py before any write, returning NOT_FOUND for an unknown id instead of an opaque APPLESCRIPT_ERROR (bead hq-wbm). AppleScript's to do id "<uuid>" unexpectedly also resolves a project uuid (Things treats projects as a "selected to do" class internally - verified live), so without this pre-check update_todo(id=<project-uuid>, ...) would silently rename/modify the project instead of failing; update_todo now rejects an id that resolves to anything other than a to-do with VALIDATION_ERROR (naming the actual type, e.g. "id 'X' is a project, not a to-do; use update_project() instead"), and a genuinely unknown id with NOT_FOUND - both returned before any AppleScript write, consistent with list_id/list_title resolution. bulk_update_todos pre-checks every id in the batch (batches are small - 2-50 ids per the documented optimal range) and excludes unresolvable ids from the AppleScript script entirely, reporting them in a new not_found list field in the response while still updating the valid ids; updated_count/failed_count/total_requested continue to reflect the full original request. If the things.py lookup itself raises (e.g. the Things database is unreadable / Full Disk Access missing), both tools fall back to proceeding with the write unchecked - the same documented fallback pattern as list_id's DB-unreadable case. delete_todo already resolved id types via things.py and required no change.
  • add_project/update_project no longer orphan a project when area_id/area_title is unresolvable (bead hq-rmh). _build_create_project_script (add_project) and update_project's AppleScript both ran the whole write - including set area of ... to area "<title>" - inside a single try block with no transactional rollback: an unresolvable area threw partway through, so add_project created a real, un-areaed orphan project (and update_project silently discarded every other field in the same call - title/notes/tags/deadline/status) while still reporting APPLESCRIPT_ERROR. Both now pre-resolve area_id/area_title via things.py (TodoOperations._resolve_area, mirroring add_todo's existing list_id/list_title pre-resolution) before any write: an unknown area_id/area_title returns NOT_FOUND and creates/changes nothing; an area_title matching more than one area returns AMBIGUOUS_TARGET (with the matching ids); a successful area_title match is normalized to its concrete area_id before the script is built. If the things.py lookup itself raises (e.g. an unreadable Things database / missing Full Disk Access), the pre-bead behavior is preserved - the raw area_id/area_title is emitted unchecked rather than refusing the write, the same documented fallback add_todo's list_id already has. add_project's URL-scheme heading-creation path (_add_project_via_url_scheme, used when a todos payload contains ## lines) shares the same pre-resolution. add_todo has no public area/area_id/area_title parameter (only list_id/list_title, already pre-resolved) so it was not affected.
  • add_tags/remove_tags/move_record now reject an empty or whitespace-only todo_id with a structured VALIDATION_ERROR instead of sending it straight through to AppleScript (bead hq-a5j). add_tags/remove_tags (tools_helpers/write_operations.py) never validated todo_id at all - ''/' ' were embedded verbatim as to do id "" / to do id " " in the generated AppleScript, unlike update_todo/delete_todo, which already call ParameterValidator.validate_non_empty_string. Both now call the same validator first and return create_validation_error_response's VALIDATION_ERROR shape (field: "todo_id") before any AppleScript call. move_record's own _validate_move_inputs (move_operations.py) only rejected a falsy/empty todo_id, not a whitespace-only one (' ' previously passed through to the AppleScript move) - it now also rejects whitespace-only ids, and its VALIDATION_ERROR response carries field: "todo_id" for this case. create_tag's tag_name parameter now declares min_length=1 in its MCP tool schema, so an empty string ('') is rejected by pydantic at the tool boundary (a ToolError) rather than reaching the AppleScript layer as a no-op call; a whitespace-only name (' ') continues to be rejected by the existing runtime guard (TAG_CREATION_FAILED, bead hq-r87), unaffected by this change.
  • The documented 100-item checklist cap is now actually enforced (bead hq-exe). CLAUDE.md and the checklist tool docstrings stated "Maximum 100 checklist items per todo", but no code path checked the count before sending checklist-items/append-checklist-items/prepend-checklist-items to the Things URL scheme - 101+ items were silently accepted and created. add_todo(checklist_items=...), add_checklist_items, prepend_checklist_items, and replace_checklist_items (scheduling/todo_operations.py's new shared _check_checklist_item_count helper, MAX_CHECKLIST_ITEMS = 100) now reject a request whose checklist_items/items list (or newline-separated string, for add_todo) exceeds 100 with a structured {"success": false, "error": "TOO_MANY_CHECKLIST_ITEMS", "field": "checklist_items"|"items", "message": "checklist supports at most 100 items, got N"} error, checked before any AppleScript/URL-scheme write - exactly 100 items is still accepted, and replace_checklist_items(items=[]) still clears the checklist as before. This is a per-request limit only: Things gives no cheap way to read how many checklist items a to-do already has, so add_checklist_items/prepend_checklist_items do not count pre-existing items on the target to-do, only the items being submitted in the current call.

Infrastructure

  • AppleScript executor now retries rc=0 results whose stdout is the in-script "ERROR:"-prefixed convention, and a duplicate dead lock was removed (bead hq-c7a). Several AppleScript script builders (move_operations.py's project/area move scripts and _get_todo_info, tag_service.py's tag-creation script) catch a failure inside their own on error handler and return "ERROR: " & errMsg instead of letting osascript itself exit non-zero - so AppleScriptExecutor._execute_script_with_retry (services/applescript/executor.py), which previously only retried on returncode != 0, treated these as a successful result and never retried them, even though Things 3 intermittently errors under rapid, unpaced back-to-back AppleEvents (AppleScript execution IS serialized process-wide via AppleScriptExecutor._applescript_lock, held around every osascript call - the failures are a pacing/burst issue, not a missing-lock issue). The retry loop now also treats a successful (rc=0) result whose stripped stdout starts with the exact literal "ERROR:" as retryable, reusing the existing bounded retry count and exponential backoff; on final exhaustion the result is still returned exactly as produced (same shape existing callers already parse via output.startswith("ERROR:")), so the failure contract is unchanged. Also removes AppleScriptManager._applescript_lock (services/applescript_manager.py) - a duplicate asyncio.Lock() that was declared but never acquired anywhere in that class (dead code); the real process-wide serialization has always lived in AppleScriptExecutor._applescript_lock. Live-measured (tests/regression/test_bulk_and_move.py, two full runs plus a targeted third): this fix does not fully eliminate bulk_move_records' known concurrency-related failed_moves under load - a distinct, narrower residual failure mode remains at move_record's own _get_todo_info pre-check intermittently reporting a just-created, genuinely-valid todo as TODO_NOT_FOUND under concurrent AppleScript bursts; the regression suite's _bulk_move_tolerating_concurrency_race retry helper was tightened to retry only that specific TODO_NOT_FOUND signature (previously it retried on any failure) so a real regression still fails the suite instead of being silently masked.
  • operation_queue's singleton no longer hangs when called from a second event loop (bead hq-5xa). get_operation_queue() held a single module-level OperationQueue whose worker task was created via asyncio.create_task(...) inside start(), permanently binding that task (and anything it awaits) to whichever event loop was running at creation time - a later call from a different event loop (e.g. an MCP client doing a fresh asyncio.run() per call) reused that stale worker task and hung awaiting/restarting it against a foreign, possibly-closed loop; reproduced live with as few as two sequential bare asyncio.run(get_operation_queue()) calls (hung at the second loop's teardown). Fixed by keying the queue by the currently-running event loop (asyncio.get_running_loop()) in a WeakKeyDictionary, so each loop gets its own OperationQueue + worker task - no cross-loop teardown/await is attempted at all (single-loop behavior is unchanged: repeated calls within one loop still return the same instance). Note: an abandoned loop's dict entry is not actually garbage-collected by this alone - the still-running worker Task strongly references its own loop, pinning the WeakKeyDictionary key for the life of the process (a small, bounded leak: one inert entry per abandoned loop, not unbounded growth or a hang); shutdown_operation_queue() avoids this for callers that control their own loop lifecycle by explicitly popping and stopping the current loop's entry before the loop is discarded. tests/regression/test_utility_tools.py's queue_status/get_server_capabilities tests, previously batched into a single shared-event-loop test as a workaround for this hang (with a docstring that misattributed the mechanism to .done() itself rather than the cross-loop worker restart), are split back into normal independent per-test call_sync calls and the docstring corrected. Also adds pytest-timeout as a declared dev dependency (pyproject.toml's dev/test extras, requirements.txt) and installs it into the dev venv, and enables the previously-commented-out timeout = 300 in pytest.ini (the actually-effective pytest config in this repo - pyproject.toml's [tool.pytest.ini_options] is ignored whenever pytest.ini is present), so a future cross-loop-hang-style regression fails the suite with a timeout instead of wedging it indefinitely.

Documentation

  • search_advanced's scope semantics are now documented (bead hq-frf) - no behavior change. The tool's docstring and CLAUDE.md ("search_advanced scope semantics", under Tag Best Practices) now spell out three pre-existing, unchanged facts: (1) without an explicit type filter, only to-dos are searched - a bare area=/start_date=/deadline= filter can never return a project or heading, so pass type='project'/'heading' explicitly to search those kinds; (2) area= matches only items directly assigned to the area and does not cascade into to-dos living inside that area's projects - use get_todos(project_uuid=...) per project, or get_areas(include_items=true) to enumerate the area's projects first; (3) there is no project= filter parameter - use get_todos(project_uuid=...) to scope a search to one project.