Make AI task capture suggestion-only, expire suggestions in 2 days, and remove task execution

Merged
GI
2mo ago
+1117-2619

Auto-created tasks were landing directly in the user's task list through four
independent paths, and the suggestion expiry the product promised was never
implemented. On one dogfood account, 121 of 124 surviving tasks were
machine-written, and 340 of 353 accepted Candidates were accepted within two
seconds of creation — machine acceptance, not a human gesture.

This establishes one invariant and rebuilds the task surfaces around it.

The invariant

I1 — an automatically extracted task is never written to the user's task
list.
Every AI-derived proposal is a pending Candidate that becomes a real
task only through an explicit user gesture ("Add to Tasks"). Surfaces that
carry a real user gesture — manual create, chat/MCP tool invocation, the
developer API — are unchanged: telling Omi to add a task is the user asking.

Corollary: a suggestion nobody acts on expires after 2 days and is gone.

What was letting tasks through

  1. auto_accept_silent / create_direct in the capture policy created a
    Candidate and accepted it in the same request, so it never existed in a
    user-visible pending state. The only gate was the extraction model's own
    self-reported confidence.
  2. The "no-drop fence": if any single item in a conversation resolved to no
    Candidate, the whole conversation fell through to the legacy writer,
    sweeping along items that had qualified as suggestions.
  3. Desktop auto-promotion. TaskPromotionService promoted staged tasks on a
    timer whenever workflow_mode != .read, and GET /v1/candidates/control
    returns off from two except branches — so a Firestore hiccup on one
    endpoint became unrequested tasks.
  4. CanonicalScreenCandidateDelivery accepted its own candidate when the
    outcome said so.

All four are closed. No workflow mode reaches legacy staging; a non-canonical
mode defers and retries rather than falling back to a writer. The capture
client protocol no longer offers an accept at all.

Expiry

Candidates carry a stored expires_at (2 days), cleared on resolution so
accepted rows survive as the audit link to their task. Reads treat a lapsed
pending Candidate as expired and derive a deadline from created_at for rows
written before the field existed, so the existing backlog ages out with no
backfill. Resolution clears the field, so a resolved Candidate has no deadline at all.

This replaces a 14-day read-time freshness filter that hid old suggestions
without ever expiring them.

Storage is not reclaimed here. A Firestore TTL policy on expires_at would
do it, but firebase_index_manifest can only express ttl: false indexing
exemptions, so a TTL policy cannot be declared through the generated manifest
today — and turning on auto-deletion for a live collection group is not
something to smuggle in through a generated file. Expired Candidates stay
stored but unreadable until that is handled deliberately.

Suggestion visibility

The Suggested surface applies a 0.8 confidence floor. direct_request and
inferred_next_step gated on it; explicit_command and clear_commitment did
not, so below the floor they produced a Candidate that could never be
displayed — stored forever, shown never. All four kinds now clear the same
floor: what the policy admits, the user sees; anything below is ignored rather
than quietly stored. test_conversation_suggestion_visibility.py walks policy
-> Candidate -> suggested projection for every capture kind so "a Candidate
exists" cannot pass for "the user was offered something".

The rule is now registered and guarded

This lands as INV-TASK-2 in docs/product/invariants/, with a static guard
wired into the checks manifest (task-capture-authority). The guard asserts
five structural facts rather than matching prose: no capture-policy outcome may
mean "create a task", extraction may not accept a Candidate, _save_action_items
may not call a writer, the desktop capture client may not expose an accept,
and a source governed by the shared capture policy may not declare an
action-item create anchor — so a future extraction writer cannot be registered
without failing it.

Run against origin/main, the guard fails on all four original paths plus the
manifest anchor; test_check_task_capture_authority.py pins that it still
fails on each of those shapes. A guard that cannot fail is decoration.

Status is proposed, not locked: the registry's promotion rule requires the
behavior and its guards to stand unchanged for seven days. Earliest promotion
2026-08-27. The behavior is enforced by CI today regardless — what locked
adds is the PR-body citation requirement.

Product changes

  • Conversation summaries show action items directly under the summary, each
    with an explicit "Add to Tasks". Items on a summary are not tasks.
  • Chat renders a native suggested-task card with "Add to Tasks", replacing the
    "Saved to Tasks" receipt that acknowledged a write the user never requested.
  • The tasks multi-select view keeps category grouping; only the row's selection
    control changes.
  • Running an agent on a task is removed — both the shipping "Execute with Omi"
    button and the legacy tmux Terminal Task Agent, along with
    RecurringTaskScheduler, whose only job was firing those investigations.

Known gaps, deliberately not fixed here

  • PENDING_CANDIDATE_REUSE_WINDOW (14 days) now outlives SUGGESTION_TTL
    (2 days), and the merge path does not refresh expires_at. A re-extraction
    of the same thing between lapse and TTL deletion merges into an invisible
    candidate, so no new suggestion appears. Self-heals once TTL reaps the doc.
    This is dedup-vs-expiry semantics — a product call, not a defect.
  • "Add to Tasks" on a conversation summary does not resolve the twin pending
    Candidate, so the same proposal can be added from the summary and still sit
    in the Suggested lane. Fixing it needs semantic matching against
    suggested_candidate_semantic_identity.
  • The dead agent* desktop SQLite columns are left in place. They are nullable
    and unwritten and GRDB ignores them; dropping them needs a migration whose
    failure mode on a live user database is worse than the untidiness.

One unrelated fix, called out deliberately

desktop-flow-lint was already failing on origin/main with two "unknown
bridge action" errors: settings_notifications_snapshot and
set_notification_settings are registered in
DesktopAutomationBridge+Notifications.swift, which was never added to
ACTION_SOURCE_RELATIVE_PATHS, so the lint could not see them. Verified by
running the lint on a clean origin/main worktree. It is one line and it
blocks any desktop push, so it is fixed here rather than worked around.

Verification

Backend: 231 tests green across the capture, candidate, contract, parity,
staged and conversation-processing suites.

Desktop: builds clean; 129 tests green on the affected suites. A full run is
5,644 tests with one failure,
RewindCaptureExclusionGenerationTests.testOwnerSnapshotStaysCurrentWhenAuthLeadsUnresolvedRewindDatabase,
which passes in isolation, is untouched by this branch, and varied across runs
— a pre-existing order-dependent flake.

Note: verified locally under Xcode 26.2; CI pins 16.4.

Product invariants affected

  • INV-AUTH-1
  • INV-CHAT-1
  • INV-NAV-1
  • INV-MEM-4
  • INV-TASK-2 (introduced here, status proposed)
  • INV-TASK-1

Failure class (fixes)

Failure-Class: none

Line-Count-Exception: desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift | 2082 -> 2086 | Four lines add the suggested-task card branch to the existing proactive-push switch; splitting a 2082-line renderer is unrelated to this change.