Repository navigation
Make the AI user profile backend-owned for every client; delete the Dream state pass - #25434
Git-on-my-level wants to merge 2 commits into
Conversation
The backend now selects the sources, synthesizes and writes
users/{uid}.ai_user_profile for every client, at most once per owner per
~20h after a processed conversation or a stale GET, plus POST
/v1/users/ai-profile/refresh and DELETE /v1/users/ai-profile. A user edit
is carried into the next synthesis as a correction. Kill switch:
AI_PROFILE_BACKEND_REFRESH_ENABLED (default on).
macOS and Windows stop generating, storing history or mirroring the
profile; they read the cloud profile through an in-memory cache and send
refresh, edit, delete and exploration through the backend. The local
ai_user_profiles tables are dropped by forward migrations.
The Dream weekly state pass and its ref-grounded profile v2 only ever
ran in shadow with no reader; they are removed with their flags, env,
tests and runbook text.
The return-only /synthesize route stays for released desktop builds.
Failure-Class: none
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A user edit or delete that lands between the refresh check and its write was overwritten by the generated text. The write now runs in a Firestore transaction that only replaces the profile when the stored text is still the synthesis input, and skips missing user docs. Also records the two new fire-and-forget refresh sites in the background-task baseline. Failure-Class: none Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
|
@kodjima33 UI changes involving Git-on-my-level require your approval. Review the current diff, then comment |
| try: | ||
| acquired = redis_db.try_acquire_ai_profile_refresh_lock(uid, ttl=10 * 60) | ||
| except Exception: | ||
| acquired = True # An explicit, rate-limited request may proceed without Redis. |
There was a problem hiding this comment.
🔒 Agentic Security Review
Severity: MEDIUM
POST /v1/users/ai-profile/refresh is an authenticated LLM spend path whose Redis-backed guards both fail open. The route-policy manifest marks users:ai_profile_refresh as fail_closed, but the handler uses with_rate_limit(...) with the default fail-open behavior, and refresh_now treats any Redis lock exception as acquisition success so the request still runs. Automatic refresh_if_stale fail-closes on the same lock error.
Impact: During a Redis outage (or any deployment where Redis commands throw), an authenticated owner can issue unbounded parallel Generate Now requests. Each call occupies llm_executor and runs two-stage synthesis plus Firestore reads, so this is shared-pool cost and availability abuse rather than a data-plane IDOR.
Reviewed by Cursor Security Reviewer for commit aff3aa4. Configure here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aff3aa414f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| """The backend-owned profile. Never blocks on generation; a stale one is refreshed in the background.""" | ||
| profile = users_db.get_ai_user_profile(uid) | ||
| if ai_profile_refresh.automatic_refresh_enabled() and ai_profile_refresh.is_stale(profile): | ||
| submit_with_context(postprocess_executor, ai_profile_refresh.refresh_if_stale, uid) |
There was a problem hiding this comment.
Move automatic profile synthesis off the postprocessing pool
When multiple stale users read their profiles or finish conversations, each refresh occupies a shared postprocessing worker throughout source collection and two synchronous LLM calls. These calls take roughly 10–40 seconds according to the new clients, and there is no cross-user concurrency limit, so a burst can occupy all 24 workers and delay conversation processing, task persistence, and other postprocessing. Both this route and the new conversation hook should start an async coordinator that dispatches database work and model calls to their respective pools. This also violates the backend guide's explicit restriction that submit_with_context is for short work, never pipelines holding a slot for more than 10 seconds.
AGENTS.md reference: backend/AGENTS.md:L271-L275
Useful? React with 👍 / 👎.
| try: | ||
| if not redis_db.try_acquire_ai_profile_refresh_lock(uid): | ||
| return 'locked' |
There was a problem hiding this comment.
Separate retry cooldown from the active refresh lock
After any automatic refresh acquires this lock, it remains held for an hour even after generation finishes or fails. refresh_now() acquires the same key, so Generate Now/Regenerate returns HTTP 409 for the remainder of that hour despite no refresh being in progress. Opening Settings itself triggers this condition for a stale profile; an insufficient-data attempt also prevents a manual retry after the user adds data. Release the active-operation lock when automatic work ends and implement automatic retry throttling separately so it does not block explicit requests.
Useful? React with 👍 / 👎.
| const p: Promise<AiProfileResult<AiUserProfile | null>> = run('read', async () => { | ||
| const profile = parseProfile(await send('GET', PROFILE_PATH, undefined, READ_TIMEOUT_MS)) | ||
| storeIfCurrent(uid, profile) |
There was a problem hiding this comment.
Reject reads that started before a profile mutation
On Windows/Linux, a background GET can read the old profile, then finish after an edit, deletion, or manual refresh has successfully updated the cache. storeIfCurrent() checks only account identity, so that older response overwrites the mutation's cached result and marks it fresh for another hour. Prompt consumers can consequently continue using text the user just corrected or deleted. Capture a mutation revision when starting the read and discard its result if a mutation has committed since then, as the new macOS service already does.
Useful? React with 👍 / 👎.
|
|
||
| static func registerMigration(on migrator: inout DatabaseMigrator) { | ||
| migrator.registerMigration("retireAIUserProfileHistory") { db in | ||
| try db.execute(sql: "DROP TABLE IF EXISTS \(retiredTable)") |
There was a problem hiding this comment.
Preserve locally saved profile changes before dropping the table
For users upgrading after a failed profile sync, this migration permanently deletes the only copy of their latest profile or manual corrections. The previous macOS generateProfile() and updateProfileText() implementations committed locally first and sent the backend PATCH in a fire-and-forget task whose failure was only logged, so the backend is not guaranteed to contain the current text. The Windows retirement migration makes the same assumption, and its previous edit path likewise retained local changes when syncing failed. Preserve pending local content through an owner-scoped migration or recovery step before dropping either table; regenerating from backend sources cannot recover user-written corrections that never reached the backend.
Useful? React with 👍 / 👎.
| let existing = await fetchProfile()?.profileText | ||
| let combined = Self.combine(existing: existing, exploration: text) | ||
| do { | ||
| let sevenDaysAgo = Calendar.current.date(byAdding: .day, value: -7, to: Date()) | ||
| let conversations = try await APIClient.shared.getConversations( | ||
| limit: 20, | ||
| startDate: sevenDaysAgo | ||
| ) | ||
| return conversations.compactMap { convo in | ||
| let title = convo.structured.title | ||
| let summary = convo.structured.overview | ||
| guard !title.isEmpty else { return nil } | ||
| return "\(title): \(summary)" | ||
| } | ||
| _ = try await updateProfileText(combined, source: .exploration) |
There was a problem hiding this comment.
Distinguish a failed profile read before appending exploration
If file exploration finishes before this service has cached a profile and its GET fails transiently, fetchProfile() returns nil just as it does when the backend genuinely has no profile. This method then builds a replacement containing only the exploration text and PATCHes it; if that request succeeds, the existing cloud profile, including any user corrections, is silently overwritten. Even with a cache, a failed read can cause replacement using an outdated copy. Require a successful authoritative read before this read-modify-write operation, or implement an atomic append on the backend, and report failure without replacing the profile when the read fails.
Useful? React with 👍 / 👎.
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |




What changed and why
David's decision (2026-11-16): one backend-owned AI user profile for every client, replacing both the client-owned v1 profile and the never-activated Dream state-pass profile v2.
Backend owns the profile.
utils/ai_profile_refresh.pyselects the sources server-side (canonical prompt memories, 50 tasks, active goals, 20 conversations from the last 7 days, 30 chat messages), runs the existing two-stage synthesis (utils/llm/ai_user_profile.py,memoriesfeature) and writesusers/{uid}.ai_user_profilewithsourceandrefreshed_at.GET /v1/users/ai-profilesees a stale profile. GET never blocks on generation.AI_PROFILE_BACKEND_REFRESH_ENABLED(default on; off stops automatic refreshes only).POST /v1/users/ai-profile/refresh(8/day; 402/409/422/502 typed errors) andDELETE /v1/users/ai-profile.PATCHrecordssource(user_edit/exploration).POST /v1/users/ai-profile/synthesizestays only for released desktop builds (documented as deprecated).macOS and Windows read it. Local generation,
/synthesizecalls, local history tables, the daily trigger and the fire-and-forget mirror PATCH are removed. Both apps read the cloud profile through an in-memory, account-bound cache and send refresh/edit/delete (macOS also exploration) through the backend. Forward migrations drop the localai_user_profilestables; the chat SQL tool no longer lists them. Settings keeps its layout; copy now says the profile is generated automatically, and refresh/save/delete failures show an inline message.Mobile needs no client change: the notes agent, MCP profile and owner-name fallback already read
ai_user_profile, which is now populated for mobile-only owners too.Dream state pass deleted. The weekly state pass and its ref-grounded
ai_user_profile_v2only ever ran in shadow with no reader (7-day shadow readout on 2026-11-15: 115 of 365 passes completed, 250 failed on response truncation or input budget). Removed with its flags, env, tests, query drivers and runbook text. Existingai_user_profile_v2fields on user docs are left in place.Gate F ratchet.
legacy_memory_surface_inventory.pytracked standalone profile synthesis for retirement under the proposed INV-MEM-6. Per the ruling above, the backend owner files (routers/users.py,database/users.py,utils/llm/ai_user_profile.py) leave that rule; client paths stay tracked and their baseline only shrinks (macOS service 47→41, Windows service 3→1, Windows db 10→1). INV-MEM-6 (status: proposed) lists "standalone profile synthesis" and "profile snapshot authority" among its MUST NOTs; this profile writes no memories, but the invariant text should be reconciled with this ruling when INV-MEM-6 is next revised.Cost estimate (not measured): one refresh is ~5–15k input and ~1k output Luna tokens (≈$0.001–0.002 at the 2026-09-22 card) plus a few hundred bounded Firestore reads, once per active owner per day. Desktop owners already paid this daily client-side; mobile-only owners are new. Usage lands on the
memoriesfeature in llm usage. Kill switch above.Product invariants affected
INV-AUTH-1: the desktop profile cache is in-memory and bound to the signed-in owner; an owner transition clears it and a read started under the previous owner is discarded on completion. No persisted owner-scoped state is added (the local table is dropped).
INV-CHAT-1: chat reads the profile as prompt context only; no transcript, session or agent state store is added. The macOS chat prompt now reads the cached profile per turn instead of once per session.
INV-MEM-4: the profile is a derived summary written to the user doc; it never creates, promotes or routes memory items.
INV-TASK-2: task assistants only read the profile text as context; no task or Candidate writer is added or changed.
How it was verified
test_ai_profile_refresh.py16 passed (cadence, kill switch, lock/compute denial, user-edit correction, edit-during-synthesis, failures never write, source formatting, transactional compare-and-set on the strict Firestore fake);test_ai_user_profile_route.py9 passed;test_firestore_query_shapes.py596 passed andtest_firestore_runtime_witnesses.py25 passed (new caller uses the existing start/end-date shape with a witness); plus the profile helper, cache, MCP, user-name and composer-context files.test_dream_*, runtime-env and flag-registry tests) run per file; all pass after the query-witness fix. Pyright clean on the three Dream modules.swift build --build-tests; 44 tests passed (AIUserProfileServiceTests,LocalProfileHistoryRetirementMigrationTests,ChatDiscoverabilityTests,ProactivityRetirementMigrationTests); SwiftLint 0 violations; swift-format, test-quality, flow-lint and strict e2e flow coverage clean.pnpm typecheck,pnpm lintandpnpm test(5548 passed) clean.Line-count exceptions
Line-Count-Exception: backend/routers/users.py | 2802 -> 2850 | DELETE and refresh routes live beside the existing GET/PATCH/synthesize AI-profile routes; a separate router would need its own role-table entry for one feature
Line-Count-Exception: backend/database/users.py | 2521 -> 2566 | write/delete/fresh-read helpers share the user_ai_profile cache policy and read path already in this module
Line-Count-Exception: backend/database/redis_db.py | 1568 -> 1578 | per-owner AI profile refresh lock, same shape as the neighbouring daily locks
Line-Count-Exception: backend/utils/conversations/process_conversation.py | 3414 -> 3417 | one fire-and-forget refresh call beside goal progress
Line-Count-Exception: desktop/macos/Desktop/Sources/Rewind/Core/RewindDatabase.swift | 3470 -> 3471 | registers the migration that drops the local ai_user_profiles table
UI evidence
UI-Evidence: none -- Settings layout unchanged; copy and an inline error line only
Failure class (fixes)
Failure-Class: none
🤖 Generated with Claude Code