Skip to content

v1.9.0 - Regression-report fixes

Choose a tag to compare

@ebowman ebowman released this 22 Aug 15:07
· 73 commits to main since this release

Fixed

  • get_todo_by_id now reports evening: true for a to-do scheduled for This Evening, instead of silently dropping that state (bead hq-wsa.9). when='evening' was previously invisible in every read tool's output - things.py's own SELECT never exposes TMTask.startBucket, so an Evening-scheduled to-do was indistinguishable from a plain when='today' to-do (both report only start='Anytime'/startDate=<today>). get_todo_by_id now reads startBucket for the single resolved to-do via a narrow, read-only (mode=ro URI) raw-SQL side channel (_read_start_bucket), gated by a cheap pre-filter (only queried when the item is a to-do with its own start_date set - never for projects/areas/headings, and never for a to-do with no start date at all) - startBucket == 1 adds evening: true to the result; any other value, or any error reading it (missing/locked database, schema change, etc.), silently omits the key rather than failing the lookup. Deliberately scoped to get_todo_by_id only - no list tool (get_today, get_todos, etc.) gained this field, to avoid multiplying this raw SQL connection per row. See CLAUDE.md's date-formats bullet 5 and "Todo field lists per mode" section for the updated contract.
  • get_todo_by_id now resolves trashed state transitively through a to-do's/heading's containing project, instead of reporting a live-looking item that is actually unreachable (bead hq-wsa.7). Things marks only the trashed container (e.g. a project moved to Trash), not each descendant - a to-do or heading filed under a trashed project carried no trashed key of its own in things.py, so get_todo_by_id previously reported it as a normal, live item. _get_todo_by_id_sync now resolves the container chain with at most two things.get() calls (item's own project, or - for a to-do under a heading with no project of its own - the heading's project) whenever the item itself is not directly trashed; if that container is trashed, the result now carries both trashed: true and a new trashedViaParent: true key, distinguishing "unreachable via a trashed ancestor" from direct trash (which keeps the existing trashed: true-only shape). Areas cannot be trashed, so there is no area hop; a failed container lookup (e.g. unreadable database) omits both keys rather than failing the lookup. Single-item lookup only - list tools already exclude trashed items via things.py's own filtering and are unaffected. See CLAUDE.md's get_todo_by_id section for the updated contract.
  • Things URL-scheme auth token no longer requires a server restart to pick up a newly-added or fixed token file, and its failure/status is now diagnosable (bead hq-wsa.4). AppleScriptManager previously loaded the auth token exactly once, in __init__; a .things-auth/things-auth.txt/~/.things-auth file created (or an empty one fixed) after the server started had no effect until the process was restarted, and neither health_check nor get_server_capabilities exposed whether a token was actually loaded, so the only way to tell was to trigger an auth-gated write and read its error. Three changes: (1) reload-on-miss - every auth gate (the shared AppleScriptManager.execute_url_scheme gate, plus the four call sites in scheduling/todo_operations.py/tools_helpers/bulk_operations.py that check the token ahead of a URL-scheme call for heading/when='evening'/when with a @HH:MM time component) now calls a new AppleScriptManager.reload_auth_token_if_missing() immediately before failing, which re-reads the candidate paths only when no token is currently loaded (a token, once loaded, is never unloaded or re-read) - a token file added or fixed after startup now works on the very next auth-gated call, no restart needed. (2) runtime visibility - health_check and get_server_capabilities (current_status) now report auth_token_configured: <bool>, the live runtime state, alongside the existing static url_scheme_support: true build-capability flag on get_server_capabilities (unchanged). (3) resolution trace - _load_auth_token now also returns a checked_paths trace ([{"path": ..., "status": "matched"|"empty"|"missing"|"unreadable"}, ...], one entry per candidate path in search order, path only - never the token value) which is included as a checked_paths field on every AUTH_TOKEN_NOT_CONFIGURED error, forwarded verbatim through _propagate_url_scheme_error (the checklist tools' and update_todo's heading-move forwarding path) alongside the existing hint. See CLAUDE.md/README's "Things URL-scheme auth token" sections for the updated (no-restart) semantics.
  • AppleScriptExecutor's serialization lock is now per-event-loop, fixing a latent cross-loop poisoning hazard (bead hq-yxu). AppleScriptExecutor._applescript_lock was a single class-level asyncio.Lock() created at import time. CPython's asyncio.Lock only binds its internal loop reference on a CONTENDED acquire (a second waiter queueing while the lock is already held) - an uncontended acquire never binds. Once a contended acquire happened on one event loop, the lock became permanently bound to that loop; a later contended acquire from a different loop (e.g. a fresh asyncio.run() per call, as the regression test harness does) then raised RuntimeError: ... is bound to a different event loop instead of serializing - a bulk_move_records call with max_concurrent > 1 reliably creates that intra-loop contention. The lock is now keyed by the currently running event loop in a weakref.WeakKeyDictionary, mirroring hq-5xa's operation_queue fix exactly: each loop lazily gets its own Lock instance via a new AppleScriptExecutor._get_lock() classmethod, so within-loop serialization is preserved while cross-loop calls never touch a foreign-bound lock. This unblocks hq-wsa.6 (whose move_operations.py change deterministically triggers the poisoning without this fix).
  • move_record/bulk_move_records now report the true pre-move original_location and a clean, unlabeled title in the success message (bead hq-wsa.6). MoveOperationsTools._get_todo_info previously round-tripped through AppleScript via a getCurrentLocation helper that was a stub always returning the literal string "inbox", and parsed the script's positional "id, name, notes, status, current_list" output with a fragile output.split(", ") - so original_location always reported 'current_list:inbox' regardless of the to-do's true origin, and the success message's title carried a stray 'name:' label (e.g. "Todo 'name:My Task' moved to ..."). _get_todo_info now reads the to-do via things.get(todo_id) instead: title/notes/status come straight from the clean field, and a new _derive_original_location helper reports the real prior location - 'project:<id>' for a to-do filed directly in a project or under a heading (resolved via the heading's own record, mirroring read_operations._fill_project_from_heading), 'inbox'/'someday'/'anytime' for the corresponding Inbox/Someday/Anytime start states, 'today' for an Anytime to-do whose start date is today or earlier (matching Things' own Today-list membership rule), and the literal ISO date string (e.g. '2026-09-01') for an Anytime to-do with a future start date (chosen over inventing an 'upcoming'-style token, since that isn't a valid move_record destination itself). If the things.get() lookup itself raises (DB unreadable / Full Disk Access missing), the move now proceeds anyway with original_location omitted entirely from the response (not present as null) and the raw id used in the message, matching the existing DB-unreadable fallback convention used by add_todo's list_id resolution. The pre-move existence check retains a short bounded retry (two 250ms re-checks) before reporting TODO_NOT_FOUND, so the existing hq-c7a concurrency-race tolerance for a just-created todo isn't worsened by this AppleScript-to-things.py switch. bulk_move_records shares this same code path (each id delegates to move_record under the hood) and is fixed identically with no field-shape change. One additional safety guard rides along: an id that resolves to something other than a to-do (e.g. a project uuid, which AppleScript's to do id lookup would previously have resolved and moved) now returns TODO_NOT_FOUND instead of moving the wrong item, mirroring hq-wbm's update_todo pre-check.
  • BREAKING (minor): search summary envelopes no longer carry a misleading total_matches key (bead hq-wsa.5). ProgressiveDisclosureEngine._summarize_search_results (context_manager.py) emitted total_matches: len(results), but the data it receives is already limit/offset-truncated by the time it gets there (server.py passes the final window down) - so e.g. limit=1 yielded total_matches: 1 even though the true, pre-limit match count (correctly reported by the envelope's separately-injected total) was much larger. total_matches is now dropped entirely rather than threaded through as a correct value; total was already correct in every mode and is the sole source of truth for the pre-limit match count. search_results_breakdown (a per-status breakdown of the returned window) is unaffected - it's explicitly window-scoped, not total-sounding. Callers reading total_matches off a search_todos/search_advanced mode='summary' response must switch to total.
  • get_projects(mode='summary') no longer always reports active: 0 (bead hq-wsa.1). ProgressiveDisclosureEngine._summarize_projects (context_manager.py) counted p.get('status') == 'open' for the active count, but things.py/convert_project emit status == 'incomplete' for open projects, never the literal string 'open' - on a live database this meant active was always 0 regardless of how many open projects existed. _summarize_projects now builds a dynamic status_breakdown from the rows' actual status values (same pattern already used by _summarize_todos) and derives active = status_breakdown.get('incomplete', 0), completed = status_breakdown.get('completed', 0), and a new canceled = status_breakdown.get('canceled', 0) field. The summary envelope's key set grows from {active, completed, recent_projects} to {active, completed, canceled, status_breakdown, recent_projects} - existing active/completed/recent_projects keys are unchanged in shape, canceled and status_breakdown are additive.
  • BREAKING (minor): structured envelopes no longer double-serialize the item payload under a second, legacy key (bead hq-wsa.2). ThingsMCPServer._read_result's dict branch extracted items from whichever source key was present (data from optimize_response, or a summary-preview key: recent_preview/recent_projects/result_preview, or get_tag_usage's tags rows list) but then kept that source key in the returned envelope via dict(response) while also setdefault-ing the same list object under items - every optimized read carried the identical item list twice (byte-identical: result['data'] is result['items'] was True). The source key is now popped once items has been populated from it, so items is the one canonical payload array; payload size on affected reads drops by roughly half. Clients that re-parsed the raw data/recent_preview/recent_projects/result_preview/tags keys directly (rather than the documented items contract) must switch to items - the documented contract itself is unchanged, and count/total/mode/requested_mode/limit/offset/meta/truncated/truncation_hint are untouched. get_tags() itself is unaffected (it's a bare-list response, routed through the list branch, not the dict branch this fix touches).

Changed

  • get_due_in_days/get_activating_in_days now accept mode/limit and route through the same response-optimization pipeline as the other date-window list tools (bead hq-wsa.3). Previously both tools passed their full, unfiltered convert_todo list straight to ThingsMCPServer._read_result's list branch, bypassing context_manager.optimize_response/_apply_field_filtering entirely - notes and every other field shipped untrimmed regardless of size, the envelope always reported mode: 'standard' even though the rows were effectively DETAILED-shaped, and there was no way to cap a large overdue backlog. Both tools now accept mode: Optional[str] (auto/summary/minimal/standard/detailed/raw, validated via the shared _validate_mode helper) and limit: Optional[int] (1-500), fetch the full window first so total reflects the pre-limit count, slice to limit client-side, and route the result through context_manager.optimize_response exactly like get_anytime/get_someday (both added to SmartDefaultManager.DEFAULT_MODES/DEFAULT_LIMITS with AUTO/40 respectively, matching get_anytime's defaults). Envelope contract change: these two tools leave the "no mode parameter" group - requested_mode now echoes the caller's actual request (None/'auto'/a concrete mode) instead of always being None; mode continues to report the concrete resolved mode. include_overdue/days behavior is unchanged. See CLAUDE.md's "Due/activating date-window tools" section for the updated contract.

Documentation

  • Documented that checklist-only edits do not bump a to-do's modificationDate (bead hq-wsa.8). Verified live: add_checklist_items/prepend_checklist_items/replace_checklist_items are thin things:///update calls, but Things tracks a checklist item's own userModificationDate on TMChecklistItem independently of the parent TMTask row - three consecutive checklist writes left the parent to-do's modificationDate pinned, while a heading move (which rewrites the task row) advanced it as expected. This is upstream Things behavior, not a bug in this server. Added a warning to the add_checklist_items/prepend_checklist_items/replace_checklist_items docstrings and add_todo's checklist_items parameter description, and a matching note in CLAUDE.md's "Managing Checklist Items" section: change-detection consumers polling modificationDate will not observe checklist-only edits - compare checklist content via get_todo_by_id(include_items=true) instead. tests/live/test_smoke.py::test_checklist_tools_require_auth_token_or_roundtrip now also asserts modificationDate is unchanged across a checklist write when a token is configured, so an unexpected upstream behavior change would be caught.
  • Documented the upstream Things completion-cascade asymmetry on update_project (bead hq-wsa.7). update_project(id=..., completed="true") cascades completion to a project's child to-dos (matching the Things app), but completed="false" does not cascade a reopen back to those children - confirmed this repo's AppleScript write is a single set status of targetProject ... with no child-iteration logic, so the asymmetry is a Things/AppleScript behavior, not a bug here. Documented in CLAUDE.md's status-semantics truth-table section and in README's update_project tool entry.