Make AI task capture suggestion-only, expire suggestions in 2 days, and remove task execution
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
auto_accept_silent/create_directin 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.- 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. - Desktop auto-promotion.
TaskPromotionServicepromoted staged tasks on a
timer wheneverworkflow_mode != .read, andGET /v1/candidates/control
returnsofffrom twoexceptbranches — so a Firestore hiccup on one
endpoint became unrequested tasks. CanonicalScreenCandidateDeliveryaccepted 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 outlivesSUGGESTION_TTL
(2 days), and the merge path does not refreshexpires_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.