From 4566c6d22b49081c6690e8667f4ee99a4e6fc44f Mon Sep 17 00:00:00 2001 From: Akarsh Hegde Date: Tue, 25 Aug 2026 14:57:46 +0530 Subject: [PATCH 01/53] fix(clerk): retry sign-in init on a network failure instead of a dead-end error The tray auto-launches at OS login/restart, often before the network is up. initClerk() then rejects (no live-API fetch to fall back from), and ClerkErrorBoundary showed a permanent "check CLERK_PUBLISHABLE_KEY and restart" message - wrong, since the key was fine and the app is fully blocked behind sign-in until the user force-quits and relaunches by hand. isLikelyClerkNetworkError classifies the rejection (navigator.onLine + known connection-failure text from both the Rust/reqwest stack and the JS fetch stack). A network-looking failure now shows a clearer message and retries automatically on the browser's `online` event or a capped backoff timer; a genuine misconfiguration keeps the original message. --- ui/__tests__/clerkNetworkError.test.ts | 62 ++++++++++++++++++++++ ui/app/setup/signin/ClerkErrorBoundary.tsx | 45 +++++++++++++--- ui/app/setup/signin/ClerkGate.tsx | 59 +++++++++++++++++--- ui/lib/clerkNetworkError.ts | 61 +++++++++++++++++++++ 4 files changed, 214 insertions(+), 13 deletions(-) create mode 100644 ui/__tests__/clerkNetworkError.test.ts create mode 100644 ui/lib/clerkNetworkError.ts diff --git a/ui/__tests__/clerkNetworkError.test.ts b/ui/__tests__/clerkNetworkError.test.ts new file mode 100644 index 000000000..cd3f9416d --- /dev/null +++ b/ui/__tests__/clerkNetworkError.test.ts @@ -0,0 +1,62 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +import { describe, it, expect, afterEach } from 'bun:test' +import { isLikelyClerkNetworkError } from '../lib/clerkNetworkError' + +// Regression coverage for a real report: a staging build showed "Sign-in +// couldn't start - check CLERK_PUBLISHABLE_KEY and restart" on a Mac right +// after a machine restart, with a correctly-configured key. Root cause: the +// tray auto-launches at login before the OS has network up, so `clerk.load()` +// rejects on a connection failure - `ClerkErrorBoundary` showed the +// misconfiguration message for what was actually a transient offline window. +// This classifier is what lets the boundary tell the two apart. + +describe('isLikelyClerkNetworkError', () => { + afterEach(() => { + // @ts-expect-error -- test-only override of a read-only DOM property + delete global.navigator + }) + + it('is true for a reqwest/clerk-fapi-rs connection failure (Rust side)', () => { + expect(isLikelyClerkNetworkError(new Error( + 'error trying to connect: dns error: failed to lookup address information: nodename nor servname provided, or not known', + ))).toBe(true) + }) + + it('is true for a plain "error sending request" reqwest wrapper', () => { + expect(isLikelyClerkNetworkError(new Error('error sending request for url (https://clerk.meridiona.com/v1/client)'))).toBe(true) + }) + + it('is true for the browser fetch failure wording (JS-side Clerk load)', () => { + expect(isLikelyClerkNetworkError(new Error('Failed to fetch'))).toBe(true) + expect(isLikelyClerkNetworkError(new Error('NetworkError when attempting to fetch resource.'))).toBe(true) + }) + + it('is true for a Safari-style offline message', () => { + expect(isLikelyClerkNetworkError('The Internet connection appears to be offline.')).toBe(true) + }) + + it('is true for a Chromium net-error code', () => { + expect(isLikelyClerkNetworkError(new Error('net::ERR_INTERNET_DISCONNECTED'))).toBe(true) + }) + + it('is true whenever the browser itself reports offline, regardless of message', () => { + // @ts-expect-error -- test-only global override + global.navigator = { onLine: false } + expect(isLikelyClerkNetworkError(new Error('some completely unrelated error text'))).toBe(true) + }) + + it('is false for a malformed/wrong-instance publishable key error', () => { + expect(isLikelyClerkNetworkError(new Error('Missing publishableKey'))).toBe(false) + expect(isLikelyClerkNetworkError(new Error('Clerk: Missing publishable_key'))).toBe(false) + }) + + it('is false for an unrelated JS error', () => { + expect(isLikelyClerkNetworkError(new TypeError('Cannot read properties of undefined'))).toBe(false) + }) + + it('handles non-Error rejection shapes without throwing', () => { + expect(isLikelyClerkNetworkError(undefined)).toBe(false) + expect(isLikelyClerkNetworkError(null)).toBe(false) + expect(isLikelyClerkNetworkError({ code: 'native_api_disabled' })).toBe(false) + }) +}) diff --git a/ui/app/setup/signin/ClerkErrorBoundary.tsx b/ui/app/setup/signin/ClerkErrorBoundary.tsx index d21a17b78..6d6e81834 100644 --- a/ui/app/setup/signin/ClerkErrorBoundary.tsx +++ b/ui/app/setup/signin/ClerkErrorBoundary.tsx @@ -3,6 +3,21 @@ import { Component } from 'react' import type { ReactNode } from 'react' +import { isLikelyClerkNetworkError } from '@/lib/clerkNetworkError' +import { Btn } from '../atoms' + +type Props = { + children: ReactNode + /** Re-mounts the gated subtree with a fresh `initClerk()` call - see + * `ClerkGate`, which passes a `key`-bumping callback so this boundary's own + * `failed` state resets along with it. */ + onRetry: () => void + /** Reported so `ClerkGate` can schedule an automatic retry (backoff timer + + * an `online` listener) when the failure looks like "no network yet" + * rather than a real misconfiguration - that orchestration needs to + * survive across remounts of this boundary, so it lives one level up. */ + onError: (error: unknown) => void +} /** Catches `initClerk()` rejecting inside `ClerkGate`'s `use()` call and shows a * message instead of leaving Suspense's child throw uncaught, which would @@ -12,23 +27,39 @@ import type { ReactNode } from 'react' * NOTE the "no key at all" case does NOT reach here: `sign_in_required` * (`commands::account`) reports false for a debug build with no key, and the * gates skip Clerk entirely rather than mounting a `ClerkGate` that is certain - * to fail. So what lands here is a key that IS configured and still didn't - * init — malformed, or the wrong instance — which is why the copy points at - * the value being bad rather than missing. */ -export class ClerkErrorBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { - state: { failed: boolean } = { failed: false } + * to fail. So what lands here is either a key that IS configured and still + * didn't init, or a transient network failure - see `isLikelyClerkNetworkError` + * for how those two are told apart, and why they need different copy: a real + * misconfiguration needs a rebuild, but a login-item launch that raced the + * OS's own network bring-up just needs a moment (v1.90.0 staging report: + * `docs/vision.md`-adjacent - this is the exact "worked after I reconnected" + * case, not a bad key). */ +export class ClerkErrorBoundary extends Component { + state: { failed: boolean; networkIssue: boolean } = { failed: false, networkIssue: false } - static getDerivedStateFromError() { - return { failed: true } + static getDerivedStateFromError(error: unknown) { + return { failed: true, networkIssue: isLikelyClerkNetworkError(error) } } componentDidCatch(error: unknown) { // eslint-disable-next-line no-console -- surfaced nowhere else; this is a dev/misconfiguration signal console.error('setup: Clerk sign-in unavailable', error) + this.props.onError(error) } render() { if (this.state.failed) { + if (this.state.networkIssue) { + return ( +
+

+ Sign-in couldn't reach the network. Meridian will retry automatically once + you're back online. +

+ Retry now +
+ ) + } return (

Sign-in couldn't start - check CLERK_PUBLISHABLE_KEY and restart. diff --git a/ui/app/setup/signin/ClerkGate.tsx b/ui/app/setup/signin/ClerkGate.tsx index 58c9b3391..cedcf2c0d 100644 --- a/ui/app/setup/signin/ClerkGate.tsx +++ b/ui/app/setup/signin/ClerkGate.tsx @@ -9,12 +9,13 @@ // about the bootstrap is identical, so it lives here once instead of being // duplicated across both widgets. -import { Suspense, use, useState } from 'react' +import { Suspense, use, useCallback, useEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import { ClerkProvider } from '@clerk/react' // eslint-disable-next-line @typescript-eslint/no-var-requires -- no type defs shipped for this community plugin import { initClerk } from 'tauri-plugin-clerk' import { isTauri } from '@/lib/bridge' +import { isLikelyClerkNetworkError } from '@/lib/clerkNetworkError' import { ClerkErrorBoundary } from './ClerkErrorBoundary' // The initialised clerk instance's type isn't exported by this community @@ -32,22 +33,68 @@ function ClerkResolve({ clerkPromise, children }: { ) } +const INITIAL_RETRY_MS = 5_000 +const MAX_RETRY_MS = 60_000 + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- see ClerkResolve +type Attempt = { key: number; clerkPromise: Promise | null } + +function freshAttempt(key: number): Attempt { + return { key, clerkPromise: isTauri() ? initClerk() : null } +} + export function ClerkGate({ notInTauriMessage, fallback, children }: { notInTauriMessage: string fallback: ReactNode // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see ClerkResolve children: (clerk: any) => ReactNode }) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see ClerkResolve - const [clerkPromise] = useState | null>(() => (isTauri() ? initClerk() : null)) + // `retry()` replaces BOTH the promise `use()` resolves and the boundary's + // `key` in one update, so a retry always means a genuinely new + // `initClerk()` call feeding a freshly-mounted (failed: false) boundary - + // not the same already-rejected promise re-thrown forever. + const [attempt, setAttempt] = useState(() => freshAttempt(0)) + const retry = useCallback(() => setAttempt((prev) => freshAttempt(prev.key + 1)), []) + + const backoffMs = useRef(INITIAL_RETRY_MS) + const pending = useRef<{ timer: ReturnType | null; onlineListener: (() => void) | null }>({ + timer: null, + onlineListener: null, + }) + + const clearPending = useCallback(() => { + if (pending.current.timer) clearTimeout(pending.current.timer) + if (pending.current.onlineListener) window.removeEventListener('online', pending.current.onlineListener) + pending.current = { timer: null, onlineListener: null } + }, []) + + // Only a NETWORK-looking failure gets auto-retried - a bad key won't fix + // itself on a timer, so that case is left to the boundary's static message + // (see ClerkErrorBoundary's doc). Two independent triggers race to whichever + // fires first: the browser's `online` event (instant once connectivity is + // back) and a backoff timer (a floor for browsers/WKWebViews that don't fire + // `online` reliably in a login-item's background window). + const handleError = useCallback((error: unknown) => { + if (!isLikelyClerkNetworkError(error)) return + clearPending() + const onlineListener = () => { clearPending(); retry() } + pending.current.onlineListener = onlineListener + window.addEventListener('online', onlineListener) + pending.current.timer = setTimeout(() => { clearPending(); retry() }, backoffMs.current) + backoffMs.current = Math.min(backoffMs.current * 2, MAX_RETRY_MS) + }, [clearPending, retry]) + + const manualRetry = useCallback(() => { clearPending(); retry() }, [clearPending, retry]) + + useEffect(() => clearPending, [clearPending]) - if (!clerkPromise) { + if (!attempt.clerkPromise) { return

{notInTauriMessage}

} return ( - + - {children} + {children} ) diff --git a/ui/lib/clerkNetworkError.ts b/ui/lib/clerkNetworkError.ts new file mode 100644 index 000000000..e5538ef75 --- /dev/null +++ b/ui/lib/clerkNetworkError.ts @@ -0,0 +1,61 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity + +// Classifies why `tauri-plugin-clerk`'s `initClerk()` rejected: a machine with +// no network yet (the login-item auto-launch races the OS's own network +// bring-up, so `clerk.load()`'s live-API fetch has nothing to reach) versus an +// actual misconfiguration (a malformed/wrong-instance publishable key). +// `ClerkErrorBoundary` uses this to pick copy that matches the real cause - +// "check CLERK_PUBLISHABLE_KEY" is true for the second case and actively wrong +// for the first, since the key is fine and the fix is just waiting for a +// connection. +// +// The Rust side (`clerk.load()` via clerk-fapi-rs -> reqwest) and the JS side +// (`@clerk/clerk-js`'s own fetch, and the CDN fetch for prebuilt UI) can each +// be the one that rejects, on either OS, so this matches substrings from both +// stacks rather than one platform's wording. False negatives (a network error +// misread as "other") just fall back to the pre-existing generic message, so +// the list is kept broad on purpose. +const NETWORK_ERROR_PATTERNS = [ + 'error trying to connect', + 'error sending request', + 'dns error', + 'failed to lookup address', + 'connection refused', + 'network is unreachable', + 'network is down', + 'could not connect', + 'timed out', + 'operation timed out', + 'failed to fetch', + 'load failed', + 'networkerror', + 'internet connection appears to be offline', + 'err_internet_disconnected', + 'err_network_changed', + 'err_name_not_resolved', +] + +function errorText(error: unknown): string { + if (error instanceof Error) return error.message + if (typeof error === 'string') return error + if (error === null || error === undefined) return '' + try { + // JSON.stringify returns `undefined` (not a string) for values like a bare + // function, which would otherwise crash the caller's .toLowerCase(). + return JSON.stringify(error) ?? String(error) + } catch { + return String(error) + } +} + +/** True when `initClerk()`'s rejection looks like "no network yet", not a bad + * key. Combines two independent signals: the browser's own connectivity flag + * (unavailable outside a browser-like runtime, hence the `typeof` guard) and + * substring-matching the error text against known network-failure wording + * from reqwest, `@clerk/clerk-js`'s fetch, and Chromium's net-error codes. */ +export function isLikelyClerkNetworkError(error: unknown): boolean { + const offline = typeof navigator !== 'undefined' && navigator.onLine === false + if (offline) return true + const text = errorText(error).toLowerCase() + return NETWORK_ERROR_PATTERNS.some((pattern) => text.includes(pattern)) +} From 23b4a6604e9335c693f6dd975fb022695cf207f9 Mon Sep 17 00:00:00 2001 From: adityaharishch Date: Tue, 25 Aug 2026 20:42:04 +0530 Subject: [PATCH 02/53] fix(ui): cut What's New down to a title and one sentence per entry Release notes had grown to paragraph-long bullets split across highlights and fixes - 1.90.0 alone ran to twelve entries, several of them sixty words. Notes nobody finishes reading are notes nobody reads. Replace both lists with a single `items` array of {title, body}, capped at three per release, and rewrite all 29 releases (and the roadmap descriptions) to that shape. Which bucket a change came from is our concern, not the reader's, so the highlights/fixes split and its bullet/FIXES chrome are gone. `release_notes_stay_short` enforces the limits (<=3 items, title <=44 chars, body <=160) rather than trusting curation - without it the file drifts back to pasted commit messages within a few releases. --- CLAUDE.md | 2 +- tray/src-tauri/resources/whats-new.json | 612 ++++++++++++----------- tray/src-tauri/src/commands/whats_new.rs | 48 +- ui/components/timeline/WhatsNewModal.tsx | 37 +- ui/lib/api-types.ts | 9 +- 5 files changed, 386 insertions(+), 322 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a608e4e53..787751cbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -602,7 +602,7 @@ the packaged-build test recipe). In short: The dashboard's "What's New" modal (`ui/components/timeline/WhatsNewModal.tsx`, opened via the toolbar nav pill or auto-opened once per app version by the tray's `poll::whats_new_auto_open`) is **hand-curated**, deliberately separate from the auto-generated `CHANGELOG.md` — that file is commit-level and too internal to show end users (e.g. `hf-proxy: bake MERIDIAN_HF_ENDPOINT into the staging channel`). 1. Edit `tray/src-tauri/resources/whats-new.json` (compiled into the tray binary via `include_str!`, not Tauri resource-bundling — a rebuild always picks up the change). -2. Add a new object to the front of `releases` (newest-first): `version`, `date`, `highlights` (features, user-facing language), `fixes`. Rewrite each bullet in plain user terms — never paste a commit message verbatim. +2. Add a new object to the front of `releases` (newest-first): `version`, `date`, and `items` — **at most three**, each a short `title` (≤44 chars) plus a `body` of **one short sentence** (≤160 chars). There is deliberately no highlights/fixes split: which bucket a change came from is our concern, not the reader's, and the split doubled the length of every entry. `release_notes_stay_short` in `whats_new.rs` fails the build if these limits are exceeded — they are the feature, not a style preference, because notes nobody finishes reading are notes nobody reads. Pick the two or three changes a user would actually notice and drop the rest; never paste a commit message verbatim. 3. Update `roadmap` if upcoming plans changed — `status` is `in-progress` | `planned` | `considering`. 4. Every string in this file is user-facing app text — plain hyphen `-` only, no em-dash, per the Hard Rules at the top of this file. 5. `cargo test -p meridian-tray` (from `tray/src-tauri/`) covers `whats_new_json_parses`, which fails the build if the JSON doesn't match the expected shape. diff --git a/tray/src-tauri/resources/whats-new.json b/tray/src-tauri/resources/whats-new.json index c9287e9dc..75e7375ab 100644 --- a/tray/src-tauri/resources/whats-new.json +++ b/tray/src-tauri/resources/whats-new.json @@ -3,433 +3,463 @@ { "version": "1.90.0", "date": "2026-08-24", - "highlights": [ - "Meridian now starts itself reliably, and comes back when you actually start work rather than at a time we picked. It used to register once and never check again, so moving the app to Applications, reinstalling, or restoring it from the Bin could leave it unable to start at login with nothing to tell you - and because Meridian records your day from the app itself, a launch that never happened meant a day with nothing in it. It now checks and repairs that registration every single launch, and the next-day restart triggers on your Mac or PC waking, unlocking or signing in, instead of a fixed hour that missed you if your laptop was shut at the time.", - "On macOS, Meridian now appears by name under System Settings - General - Login Items & Extensions, so you can see it and turn it off there like any other app. It used to install itself as an unnamed background job, and uninstalling left that job behind pointing at an app that no longer existed.", - "If your project tool is not one we support yet, you can now tell us which one you use instead of being stuck on that step." - ], - "fixes": [ - "Creating a task on GitHub from your daily plan works properly now. It used to fail outright if your project board had no GitHub task on it yet, and even when it worked the new issue never came back into Meridian - it was created but never added to your board or assigned to you, which is what Meridian looks for. New tasks are now filed, assigned to you and placed on your board. You will need to reconnect GitHub in Settings - Integrations once, because adding an issue to a board needs a permission the old connection did not ask for.", - "The end-of-day time you pick during the guided tour is actually saved. The tour would move on as though you had answered, and Settings still showed the old default afterwards.", - "The guided tour no longer gets stuck on that same question. Dismissing the dialog could leave the tour waiting on a button that was no longer on screen, and it pointed at the Turn on button before choosing a time had switched it on.", - "The end-of-day time dialog no longer appears twice if you skip the tour while it is opening, and it now asks you to pick a time before it will save.", - "Meridian no longer reports your project tool as failing to sync just because your laptop was asleep. A gap in time was being treated the same as a run of failures, so the first attempt after waking raised a warning about a tracker that was working fine.", - "Signing in sticks across a relaunch. A session that was still loading reported an empty status, which Meridian read as a failed sign-in and quietly dropped, so you were shown the sign-in screen again.", - "Several fixes that protect your database around a restart: the write-ahead log is now flushed cleanly when the daemon stops, damage is spotted from Meridian's own reads rather than waiting for a scan, an automatic repair that fails now falls back to a fresh start instead of leaving you stuck, and Meridian no longer holds a connection open across a daemon restart.", - "On Windows, an automatic repair no longer gives up when a file is briefly locked by another process, and updating your start-at-login setting no longer closes Meridian while it does so.", - "The tray popover has a cleaner edge - the window is clipped to its own rounded corners and no longer draws a second shadow behind it." + "items": [ + { + "title": "Starts reliably, every time", + "body": "Meridian repairs its own start-at-login registration on every launch, and comes back when your machine wakes instead of at a fixed hour." + }, + { + "title": "GitHub tasks from your plan", + "body": "New issues are filed, assigned to you and placed on your board - reconnect GitHub in Settings once to grant the permission this needs." + }, + { + "title": "Steadier around restarts", + "body": "Several fixes protect your database when Meridian stops, restarts or repairs itself." + } ] }, { "version": "1.89.0", "date": "2026-08-20", - "highlights": [ - "Ollama Cloud joins the setup wizard as a free, no-subscription way to draft with AI, and is now the one we recommend if you don't already have a subscription. Groq is being retired from active use - if you're currently on it, Settings will show a notice pointing you to Ollama, and your configuration stays exactly as you left it until you switch.", - "Settings now shows exactly what daily usage reporting sends - a health snapshot alongside the counts, never your screen activity or anything you wrote - and a new switch lets you turn it off separately from error reporting, without losing crash reports." - ], - "fixes": [ - "Signing in sticks even on a cold start with no network. A session that was still loading could report its status as empty, which Meridian was reading as a failed sign-in and quietly dropping - so a user who had genuinely signed in before would see the sign-in screen anyway.", - "Codex and Cursor sign-in are detected more reliably. A browser sign-in that visibly succeeded could still get reported back to Meridian as failed; it no longer does.", - "Draft with AI on Cursor no longer takes up to a minute when it usually takes seconds.", - "A brief network or provider hiccup no longer flips your AI provider to unavailable and stops your day from being written - Meridian retries once before giving up.", - "Several worklog drafts falling behind in the same hour now arrive as one notification instead of landing all at once.", - "Drafting stopped failing with a hidden schema error on some custom AI endpoints.", - "Coding-agent session summaries no longer silently fail to generate on some setups.", - "A database repair that fails on launch now retries automatically within the same session instead of leaving Meridian stuck until you relaunch.", - "Testing a specific AI provider's connection is more reliable when you have more than one free-tier key configured - it could fail outright, or show one endpoint's result on another's card." + "items": [ + { + "title": "Ollama Cloud, free to use", + "body": "A no-subscription way to draft with AI, and the one we now recommend if you don't already have a subscription." + }, + { + "title": "Usage reporting you can switch off", + "body": "Settings shows exactly what daily usage reporting sends, with its own switch separate from error reports." + }, + { + "title": "Sign-in and drafting fixes", + "body": "Signing in sticks on a cold start, and a brief provider hiccup no longer stops your day being written." + } ] }, { "version": "1.88.0", "date": "2026-08-17", - "highlights": [ - "The guided tour no longer blocks the very buttons it asks you to press. Choosing your project tool, answering the AI question, even typing a task title - the tour was covering all of it, so you could read the instruction but not follow it, and the step only moved on when it timed out minutes later.", - "The daily summary now updates a work log the moment you post it. A row you had just filed still said DRAFT READY TO POST, in the colour reserved for things that need you, until you closed the summary and opened it again.", - "The installer now says to open Meridian after dragging it. A disk image cannot start an app itself, so the app sat in Applications doing nothing while the window that put it there gave no hint a second step was left." - ], - "fixes": [ - "Permission warnings no longer fire on launch when nothing is wrong. Rebuilding or updating the app made macOS report Screen Recording and Accessibility as off for a moment; the toast fired on that first read and then cleared itself. A permission now has to read off for five minutes before you hear about it.", - "The tour no longer leaves the app clickable while it narrates over a window it opened. A stray click could close the modal it was describing, after which it carried on explaining a screen that was no longer there.", - "The planner no longer reports Sync failed for a sync that worked.", - "The setup wizard's permission cards line up. The Screen Recording title wraps to two lines where Accessibility does not, which pushed everything below it a line lower in that one card, and the notifications card drew a different header from the two beside it.", - "The setup window no longer resizes itself when macOS or Windows cannot say whether it is maximized. On Windows a resize clears the maximized state, so the window would snap back to its old size mid-wizard when you pressed Continue.", - "The dashboard builds no longer depend on a font downloaded at build time, which had started failing and taking the build with it.", - "The open-source card fits its buttons at every width, and leads with starring the repo." + "items": [ + { + "title": "The tour stays out of your way", + "body": "It no longer covers the very buttons it asks you to press." + }, + { + "title": "Work logs update as you post", + "body": "A row you just filed no longer keeps saying it is a draft waiting for you." + }, + { + "title": "Fewer false permission warnings", + "body": "A permission has to read as off for five minutes before Meridian mentions it." + } ] }, { "version": "1.87.0", "date": "2026-08-16", - "highlights": [ - "Notifications on Windows can now be answered. Buttons and the reply box did nothing there - the only way a question ever closed itself was by expiring - and a notification Meridian tried to take back stayed in the Action Center with buttons that still looked live. Both now work the way they already did on Mac.", - "The guided tour can be replayed. Settings - Account - Show me around brings it back. Until now that control existed only in development builds, so once you finished or skipped the tour there was no way to see it again.", - "The tour writes your first task for you instead of waiting at an empty box. It used to ask you to invent one before you had watched Meridian draft anything, which is the wrong moment to ask - the step straight after is the one where a scruffy one-liner becomes a titled, described task." - ], - "fixes": [ - "On Windows the dashboard no longer opens with no title bar and no taskbar. It covered the whole screen with nothing to click - no minimize, no close - and the only ways out were Alt+F4 and Alt+Tab. It now fills the usable screen with its window controls intact.", - "On Windows, pressing Continue in the setup wizard no longer shrinks a maximized window back down to its smaller size.", - "The setup wizard no longer stops on the Alerts step when there is nothing to ask. Windows switches notifications on for most apps already, so that step usually opened with its one setting done.", - "The setup wizard's Continue button is easier to read - the old fill was pale enough that the button looked half-disabled.", - "Installing an update no longer races Meridian's own health check, which could put the background service back while the file it runs from was still being replaced.", - "Windows no longer logs a notification error every time Meridian starts.", - "Pressing Generate on a work log with no AI connected now takes you somewhere. The Choose a provider button opened Settings behind the card you were still looking at, so the one route the app offers for connecting a model appeared to do nothing.", - "The tour no longer demonstrates generating a work log on a machine with no AI set up at all.", - "The tour's spotlight no longer shows square corners around a rounded card, and its narration bar no longer covers the first line of what it is talking about.", - "Writing a task, being sent off to connect an AI provider, and coming back now returns you to what you were typing rather than to the task list.", - "Finishing the tour no longer asks a second time when you want your work log written." + "items": [ + { + "title": "Notifications work on Windows", + "body": "Buttons and the reply box now do what they already did on Mac." + }, + { + "title": "Replay the guided tour", + "body": "Settings - Account - Show me around brings it back any time." + }, + { + "title": "The Windows dashboard behaves", + "body": "It opens with its title bar and window controls intact instead of covering the whole screen." + } ] }, { "version": "1.86.0", "date": "2026-08-16", - "highlights": [ - "Setup now picks up where you left off. Closing the wizard part-way through used to mean starting again from the first card.", - "Meridian tells you when your daily summary is ready, instead of leaving you to go and look.", - "The dashboard opens filling the screen rather than in a small window you have to resize every time." - ], - "fixes": [ - "Signing in sticks. Your session was being quietly dropped every time Meridian restarted, so you had to sign in again each launch.", - "Settings no longer crashes on builds where sign-in is switched off.", - "The vendored sign-in component was writing your session token into Meridian's own logs. It no longer does, and that logging is now switched off by default regardless.", - "The wizard's privacy wording now matches what the app actually does. It said Meridian never collects anything - true of a build you compile yourself, but not of the installed app, which sends redacted error reports and product analytics you can switch off. The download page, the privacy document and the wizard all say the same thing now.", - "The Notifications permission card in setup now shows the real Notifications pane rather than a stand-in, and the permission cards are highlighted one at a time instead of all at once.", - "The setup wizard no longer renders black when the window is in full screen.", - "What's New no longer opens on top of you the moment first-run setup finishes.", - "The walkthrough survives a stray click, and the tray's own pop-ups no longer interrupt it half way through.", - "Connecting GitHub no longer blames your token when GitHub simply cut the response short. Meridian retries instead." + "items": [ + { + "title": "Setup picks up where you left off", + "body": "Closing the wizard part-way no longer means starting again from the first card." + }, + { + "title": "Signing in sticks", + "body": "Your session is no longer quietly dropped every time Meridian restarts." + }, + { + "title": "Told when your summary is ready", + "body": "Meridian tells you, instead of leaving you to go and look." + } ] }, { "version": "1.85.0", "date": "2026-08-13", - "highlights": [ - "Meridian shows you around the first time you open it. A guided walkthrough covers planning a day, watching your work get picked up, and having a work log written for you - starting on your own day, then on an example day so you can see a full one end to end.", - "Yesterday's unfinished work now moves into today on its own, so you no longer start the morning rebuilding a list you already made.", - "Meridian now says when a draft work log has fallen behind the work it describes, rather than letting you post something that stopped being true an hour ago." - ], - "fixes": [ - "Uninstalling now removes your data. Some of it survived every uninstall route the app offered, which is not what uninstall means.", - "An update already being installed is no longer reported as 'Update failed', and an install that genuinely fails now tells every screen, so nothing is left sitting on a spinner.", - "Restarting the background service no longer runs into the limit macOS puts on how often a service may restart, which used to leave it stopped for minutes at a time.", - "A work-log draft that the AI failed to produce now points you at the reason rather than ending the flow with nothing.", - "Every notification now opens the thing it is about. Some of them went nowhere. The board-hygiene digest, which fired at midnight and which nobody ever acted on, is gone.", - "A fresh install opens setup rather than dropping you on an empty dashboard.", - "The work-log draft is laid out as a document you can read, and the live hour on your timeline now says which hour it means." + "items": [ + { + "title": "A guided walkthrough", + "body": "Meridian shows you around the first time you open it, first on your own day and then on an example day." + }, + { + "title": "Unfinished work rolls over", + "body": "Yesterday's open tasks move into today on their own." + }, + { + "title": "Uninstall removes your data", + "body": "Data that used to survive an uninstall is now properly cleared." + } ] }, { "version": "1.84.0", "date": "2026-08-07", - "highlights": [ - "Meridian now checks its database when it starts and repairs a damaged one on its own. Until now the offer to repair lived inside the database itself, so the worst damage took the offer down with it - the app simply sat there doing nothing, with no warning and no button to press, and the only way back was a support ticket. That case now heals itself before the app finishes opening.", - "Quit now really does stop Meridian. The background service used to keep running after you closed the app: still watching, still holding your data file open, still making network calls. Quitting stops it, and opening Meridian starts it again.", - "Pause now actually pauses. The menu switched to 'Disconnected' but the background service was put straight back a few seconds later, so it kept working while the menu said it had stopped." - ], - "fixes": [ - "The 'Repair Database' button in the damaged-database banner works again. It had been doing nothing at all since it shipped - the confirmation box it asked for never appeared, and a box nobody answered counted as 'no', so the repair was silently cancelled every time.", - "Repairing by hand used to be impossible for the same reason Quit was: the instructions said to close Meridian first, and closing it did not stop the part that had to stop.", - "Meridian can no longer get stuck refusing to quit. If something went wrong while shutting down, every later attempt to close the app was ignored and the only way out was to force it.", - "If a damaged database cannot be opened at all, Meridian now leaves it alone unless it can confirm the file is genuinely damaged rather than locked. A file it cannot unlock looks identical to a broken one, and the safe assumption is that your data is fine and something else is wrong.", - "On Windows, the instructions shown when a database needs repairing now cover installs that start Meridian from the Startup folder, not only those using a scheduled task.", - "Pausing while Meridian was still setting itself up no longer leaves it running behind a paused label." + "items": [ + { + "title": "A self-repairing database", + "body": "Meridian checks its database at startup and rebuilds a damaged one on its own." + }, + { + "title": "Quit really quits", + "body": "Closing the app now stops the background service with it." + }, + { + "title": "Pause really pauses", + "body": "Pausing no longer puts the background service straight back a few seconds later." + } ] }, { "version": "1.83.2", "date": "2026-08-05", - "highlights": [ - "Meridian no longer reports its own background service as stopped when it is merely busy. The popover said 'Daemon: Not running' and offered a Restart button while recording carried on normally - and restarting at that moment is one of the ways the database gets damaged.", - "The 'database is damaged' banner now clears itself once a repair succeeds. It used to survive the very repair that fixed it, so people saw a warning about a problem that no longer existed - in one case twelve hours and two repairs later." - ], - "fixes": [ - "Every notice banner now shows when it was raised, so a warning left over from this morning is obvious at a glance.", - "Fixed the background service's health check shutting down for good after one momentary error. Every screen then reported the service as stopped, next to a Restart button, while it was in fact still recording, and only restarting it cleared that.", - "Versions older than this one now install this update on their own rather than waiting for you to click, so a machine left on an affected build does not stay there." + "items": [ + { + "title": "Honest service status", + "body": "Meridian no longer reports its background service as stopped when it is merely busy." + }, + { + "title": "The damaged-database banner clears", + "body": "It now disappears once a repair succeeds, instead of outliving the fix." + } ] }, { "version": "1.83.1", "date": "2026-08-04", - "highlights": [ - "The Repair Database button now actually works. In the last version, clicking through it did nothing at all - the confirmation box it relied on is invisible in the app, so repairing a damaged database meant using a terminal.", - "Three causes of database damage on Mac are fixed. One could strike while Meridian upgraded your data to encrypted storage, one on any busy day once Meridian ran out of the file handles macOS allows it, and one when Meridian restarted its own background service in the middle of a write because a health check mistook 'busy' for 'stopped'.", - "When a work log action fails, Meridian now tells you why. Approving, rejecting, posting and editing used to fail silently - the row simply snapped back to its old value with no explanation." - ], - "fixes": [ - "Fixed the one-time encryption upgrade running while the background service was still writing to your data, which could leave the database damaged. Meridian now stops the service first, checks nothing else is still using the file, and leaves your data alone entirely if it cannot.", - "Fixed Meridian running out of file handles on Mac, which could damage the database in the middle of a write. It now asks the system for enough at startup.", - "Fixed Meridian restarting its own background service roughly every 45 seconds on a busy machine. The service was healthy every time; the check that judged it simply gave up waiting too early.", - "On Windows machines where company policy blocks scheduled tasks, restarting no longer reports a failure every single time. The backup method was already working - only the false alarm is gone.", - "Error reports now say which health check failed, rather than only that one did.", - "Fixed the Windows build of the app, which had been broken by the database fixes above." + "items": [ + { + "title": "Repair Database works", + "body": "Clicking through the repair no longer silently does nothing." + }, + { + "title": "Three causes of database damage fixed", + "body": "The one-time encryption step, running out of file handles, and needless restarts of the background service." + }, + { + "title": "Work log errors explain themselves", + "body": "Approving, rejecting, posting and editing now say why they failed." + } ] }, { "version": "1.83.0", "date": "2026-08-03", - "highlights": [ - "Meridian now notices when its own database has been damaged and can rebuild it from inside the app, with no terminal needed. Your data is salvaged into a fresh file, the damaged copy is kept as a backup and never deleted, and the background service stands down while the work happens.", - "A banner tells you when the database is damaged, and offers the repair right there rather than leaving you to find it." - ], - "fixes": [ - "The Install button for Claude and Codex now works on Windows. It used to report that the installer had finished and the tool was still missing, when in fact nothing had been installed at all.", - "The 'provider unavailable' banner now catches an assistant that is installed, tested fine an hour ago, and has been failing every request since. Before, your day could quietly stay empty while the banner insisted everything was fine." + "items": [ + { + "title": "Repair a damaged database in the app", + "body": "Your data is salvaged into a fresh file and the damaged copy is kept as a backup." + }, + { + "title": "The Install button works on Windows", + "body": "Claude and Codex now genuinely install rather than just reporting success." + } ] }, { "version": "1.82.3", "date": "2026-08-01", - "highlights": [], - "fixes": [ - "A failure while Meridian was starting up no longer takes the whole app down with it. It now reports the problem and carries on.", - "Solo users can see their confirmed daily plan again." + "items": [ + { + "title": "Startup failures are survivable", + "body": "A problem while Meridian is starting no longer takes the whole app down." + }, + { + "title": "Solo plans are visible again", + "body": "Solo users can see their confirmed daily plan." + } ] }, { "version": "1.82.2", "date": "2026-07-31", - "highlights": [ - "A brand-new install now works on its very first launch. Meridian used to look for its database moments before the background service had created it, then never look again - so the dashboard stayed empty and nothing was recorded until you quit and reopened the app." - ], - "fixes": [ - "Meridian no longer claims your data is encrypted under a lost key when the file is actually empty or cut short. That warning told people to contact support before removing anything, when deleting the leftover stub was all that was needed.", - "Meridian now refuses to create a new encryption key while your existing data is still encrypted under an older one - which would have made that data permanently unreadable. It stops and explains instead of quietly locking you out.", - "The tray tooltip no longer gets stranded on screen after a right-click.", - "Windows: notifications are no longer reported as blocked when the system simply has no record of them yet.", - "Windows: the background service can now open an encrypted database, instead of failing to find the settings it needed.", - "Dropped a health check that kept reporting a problem which no longer exists." + "items": [ + { + "title": "First launch just works", + "body": "A brand-new install records from its very first launch, with no quit and reopen." + }, + { + "title": "Clearer encryption warnings", + "body": "Meridian no longer claims your data is locked under a lost key when the file is simply empty." + } ] }, { "version": "1.82.1", "date": "2026-07-29", - "highlights": [], - "fixes": [ - "A behind-the-scenes release - build and packaging fixes only, with no change to how Meridian works day to day." + "items": [ + { + "title": "A behind-the-scenes release", + "body": "Build and packaging fixes only, with no change to how Meridian works day to day." + } ] }, { "version": "1.82.0", "date": "2026-07-29", - "highlights": [ - "If you open Meridian straight from the disk image without dragging it to Applications first, it now offers to move itself there and reopen. Before, it worked for that session but could never start itself again after a restart.", - "Windows setup now includes a notification permission step, so reminders work from the start rather than silently doing nothing.", - "Meridian pauses capture when your disk is nearly full instead of writing into it. Doing so is a known way for the database to end up damaged." - ], - "fixes": [ - "Signing in to Claude and Codex no longer fails with 'env: node: No such file or directory'.", - "Windows: the one-time encryption of your existing data now runs to completion, instead of being blocked by the background service still holding the file open.", - "If the background service cannot start at all, that now reaches the team as an error report. It used to be completely invisible from our side." + "items": [ + { + "title": "Move to Applications on first run", + "body": "Opened straight from the disk image, Meridian offers to move itself there and reopen." + }, + { + "title": "Notification permission on Windows", + "body": "Setup now asks for it, so reminders work from the start." + }, + { + "title": "Pauses when your disk is nearly full", + "body": "Writing into a full disk is a known way for the database to end up damaged." + } ] }, { "version": "1.81.0", "date": "2026-07-28", - "highlights": [ - "If Meridian cannot finish encrypting your data because a copy of it is still running, it now says so plainly and tells you how to finish the job - rather than quietly leaving your database unencrypted.", - "While you are signed in, your Support ID now stays the same across all of your devices, so support can piece together what happened if you are testing an early build." - ], - "fixes": [ - "Fixed Meridian being unable to open its own database after an interrupted encryption step. On Windows this could stop tracking altogether, and it did not recover on its own.", - "Tracker sync no longer tells you to check your credentials when the real problem is a passing network glitch. It stays quiet, retries on its own, and only raises it if the connection stays broken.", - "When a sync problem does need you, the message now says what actually went wrong and points you to Settings - instead of naming a file most people have never opened.", - "Tracker sync can no longer hang forever waiting on a server that has stopped responding." + "items": [ + { + "title": "Clearer encryption messages", + "body": "If Meridian cannot finish encrypting your data, it says so plainly and tells you how to finish the job." + }, + { + "title": "Calmer tracker sync", + "body": "A passing network glitch no longer looks like a credentials problem." + } ] }, { "version": "1.80.0", "date": "2026-07-28", - "highlights": [ - "Everything Meridian has captured is now encrypted on disk. The key is kept in your system keychain, and your existing data is upgraded automatically the first time you open this version - there is nothing to set up.", - "Meridian can now send error reports to the team automatically, so crashes and failures get fixed without you having to report them. It is on by default and you can turn it off any time in Settings - Capture.", - "Everything identifying is stripped on your device before a report is sent - file paths, web addresses, email addresses, and your computer's name. Your screen activity, OCR text, and window titles are never included.", - "A new Support ID in Settings - Account. Quote it when you contact us and we can find the errors from your device, without it being linked to your account." - ], - "fixes": [ - "Error reports now work on Windows. They were silently never being sent.", - "Problems in screen capture and accessibility - the parts that read what is on screen - are now reported, so a device that quietly stops capturing can be spotted instead of just looking like an idle day.", - "Error reports now carry the actual reason something failed, instead of only naming the step that failed." + "items": [ + { + "title": "Your data is encrypted on disk", + "body": "The key lives in your system keychain and your existing data is upgraded automatically." + }, + { + "title": "Automatic error reports", + "body": "Crashes reach us without you reporting them, and you can turn this off in Settings - Capture." + }, + { + "title": "Nothing identifying leaves your device", + "body": "Paths, web addresses, email addresses and your computer's name are stripped before anything is sent." + } ] }, { "version": "1.79.0", "date": "2026-07-24", - "highlights": [ - "Claude, Codex, and Cursor now sign in and run on Windows the same way they do on a Mac.", - "Meridian restores its own launch-at-login setting if it ever gets cleared, so tracking does not quietly stop after a restart.", - "Notification settings are now a single switch plus quiet hours, instead of a list of per-type toggles." - ], - "fixes": [ - "Fixed sign-in for Claude, Codex, and Cursor on Windows, including prompts that were being dropped.", - "A brief network problem no longer leaves Jira showing a stuck sync error.", - "Export Diagnostics is reachable again, now under Settings - Account." + "items": [ + { + "title": "AI providers on Windows", + "body": "Claude, Codex and Cursor sign in and run the same way they do on a Mac." + }, + { + "title": "Launch at login repairs itself", + "body": "Tracking no longer quietly stops after a restart." + }, + { + "title": "Simpler notification settings", + "body": "One switch plus quiet hours, instead of a list of per-type toggles." + } ] }, { "version": "1.78.0", "date": "2026-07-23", - "highlights": [ - "Meridian notices within about ten seconds if its background service stops, and restarts it for you.", - "You can delete personal tasks you created, and dismiss or merge the tasks Meridian infers from your day.", - "The daily plan now holds up to 20 tasks, up from 10." - ], - "fixes": [ - "Fixed the background service failing to restart on Windows, and a console window that flashed on screen.", - "The Tasks board refreshes immediately after you delete a personal task.", - "Uninstalling now also clears cached app data and permission grants." + "items": [ + { + "title": "The background service restarts itself", + "body": "Meridian notices within about ten seconds if it stops." + }, + { + "title": "Tidy up your tasks", + "body": "Delete personal tasks, and dismiss or merge the ones Meridian infers from your day." + }, + { + "title": "Room for 20 tasks a day", + "body": "The daily plan now holds twice as many as before." + } ] }, { "version": "1.77.0", "date": "2026-07-22", - "highlights": [ - "A rebuilt daily summary, organised around what you planned versus what you actually did, with a progress ring and a single checklist.", - "Composing the summary has its own screen now, and it composes itself at the end of the day.", - "Worklogs can turn a personal task into a real ticket, and write a tailored update for each ticket you moved forward.", - "The date picker is now a proper interactive calendar." - ], - "fixes": [ - "Fixed a crash in accessibility capture when the recorder restarted." + "items": [ + { + "title": "A rebuilt daily summary", + "body": "Organised around what you planned versus what you actually did, with a progress ring and a single checklist." + }, + { + "title": "Summaries compose themselves", + "body": "Your summary is written at the end of the day, on a screen of its own." + }, + { + "title": "A work log per ticket", + "body": "A tailored update for every ticket you moved forward." + } ] }, { "version": "1.76.0", "date": "2026-07-22", - "highlights": [ - "Meridian captures context from secondary monitors, not only the screen you are focused on.", - "Connected tracker cards have View tasks and Sync now buttons.", - "Jira gets a multi-select project picker, matching the one GitHub already had." - ], - "fixes": [ - "Fixed connecting an account not opening a browser on Windows.", - "Fixed the task list briefly showing stale results while loading." + "items": [ + { + "title": "Secondary monitors are captured", + "body": "Meridian reads context from every screen, not only the one you are focused on." + }, + { + "title": "Act on a tracker from its card", + "body": "View tasks and Sync now sit right where the tracker is connected." + } ] }, { "version": "1.75.1", "date": "2026-07-22", - "highlights": [], - "fixes": [ - "Fixed Meridian finding and launching your AI provider CLIs on Windows.", - "Fixed the popover position, tooltips, onboarding, and setup wizard on Windows.", - "Meridian now reads your real Windows notification and permission settings instead of assuming they are off." + "items": [ + { + "title": "Windows polish", + "body": "Your AI provider tools are found, and the popover, tooltips and setup wizard all behave." + } ] }, { "version": "1.75.0", "date": "2026-07-21", - "highlights": [ - "Signing in is now required across the dashboard and the tray popover.", - "The plan's task detail can change a task's status, including for personal tasks.", - "You can choose which tracker a proposed ticket gets created on.", - "A Refresh button on the daily plan once a tracker is connected." - ], - "fixes": [ - "Streamlined the GitHub connect flow, including the device-code step and picking projects." + "items": [ + { + "title": "Sign-in across the app", + "body": "The dashboard and the tray popover now both require signing in." + }, + { + "title": "Change a task's status from its detail", + "body": "Personal tasks included." + }, + { + "title": "Choose where a ticket is created", + "body": "Pick the tracker a proposed ticket gets filed on." + } ] }, { "version": "1.74.0", "date": "2026-07-20", - "highlights": [ - "Worklogs can draft themselves once a day, at a time you choose." - ], - "fixes": [ - "Fixed the GitHub project picker not saving after connecting an account.", - "Fixed Dock and menu-bar icon rendering." + "items": [ + { + "title": "Work logs draft themselves", + "body": "Once a day, at a time you choose." + }, + { + "title": "The GitHub project picker saves", + "body": "Your board selection now sticks after connecting." + } ] }, { "version": "1.73.0", "date": "2026-07-20", - "highlights": [ - "Windows support. The full app - background service, tray, notifications, and installer - now runs on Windows.", - "Choosing a model is now a picker with a curated list, instead of typing the model name yourself." - ], - "fixes": [ - "Fixed screen capture on macOS 26.", - "Hardened worklog and activity processing so a failure surfaces instead of passing silently.", - "Bounded the memory and time the on-device text model can use." + "items": [ + { + "title": "Windows support", + "body": "The background service, tray, notifications and installer all run on Windows." + }, + { + "title": "Pick a model from a list", + "body": "A curated picker, instead of typing the model name yourself." + } ] }, { "version": "1.72.0", "date": "2026-07-18", - "highlights": [ - "Choose apps and websites for Meridian to ignore, so nothing from them is ever captured. Set them up in Settings.", - "Bring your own cloud AI provider: add any OpenAI-compatible endpoint from the provider picker and Meridian will use it for worklogs and summaries.", - "Test your AI provider connection from Settings or during setup, so you can confirm it works before relying on it.", - "A new AI-composed daily summary that reads like a summary of your day, with the charts chosen to fit what actually happened.", - "The day timeline shows the current hour as a live strip again, so you can watch the hour fill in as you work.", - "Worklogs now follow the day's plan and post to every ticket you actually moved forward.", - "Added a \"suggest a feature\" link to the roadmap in What's New." - ], - "fixes": [ - "Fixed a memory leak that made Meridian's memory use climb while screen capture was running.", - "Fixed duplicate and missing worklogs when the same hour was processed more than once.", - "Fixed worklog drafting failing on the Codex provider, and it now reports the real reason when it does fail.", - "Fixed a failed update showing a stale download percentage when you retried it.", - "Fixed modals scrolling instead of fitting, clipped task titles, and changing a task's status from Cleanup.", - "Fixed a task card's summary clipping mid-line instead of leaving room for the \"+N more\" label.", - "Made the AI provider picker easier to read." + "items": [ + { + "title": "Ignore apps and websites", + "body": "Choose what Meridian never captures, in Settings." + }, + { + "title": "Bring your own AI provider", + "body": "Add any OpenAI-compatible endpoint from the provider picker." + }, + { + "title": "An AI-composed daily summary", + "body": "It reads like a summary of your day, with the charts chosen to fit what happened." + } ] }, { "version": "1.71.0", "date": "2026-07-15", - "highlights": [ - "Redesigned dashboard look: refined typography, a single violet accent, and real per-app brand colors.", - "Sign in with just your email and a one-time code, then manage your account from Settings.", - "Meridian is now signed and notarized by Apple, so macOS won't flag it as an unidentified app.", - "Added an in-app uninstall wizard.", - "The \"Plan your day\" view now opens automatically once a day to help you start with a plan.", - "Onboarding now shows an expected-memory gauge and a notifications permission step." - ], - "fixes": [ - "Fixed navigating to a specific view sometimes not working if the dashboard was already open.", - "Fixed worklog creation failing for Jira projects that don't have a Bug issue type.", - "Fixed worklogs repeatedly failing to post once a Jira issue hit its daily worklog limit.", - "Fixed a bug that could trigger duplicate Jira worklog backfills.", - "Fixed the notifications permission link opening the wrong settings pane." + "items": [ + { + "title": "A redesigned dashboard", + "body": "Refined typography, a single violet accent and real per-app brand colors." + }, + { + "title": "Sign in with a one-time code", + "body": "Just your email, then manage your account from Settings." + }, + { + "title": "Signed and notarized by Apple", + "body": "macOS no longer flags Meridian as an unidentified app." + } ] }, { "version": "1.70.0", "date": "2026-07-09", - "highlights": [ - "Track work automatically across GitHub, Linear, Azure DevOps, Jira, Trello, and Asana.", - "Pick which GitHub Projects (v2) board to sync right from the connect flow.", - "Sign in to GitHub natively - no more copy-pasting a command-line login.", - "Popover: tap the \"Capturing\" status to see a live health panel.", - "Onboarding shows expected memory usage and downloads models faster.", - "Redesigned Overview and Hour-detail views." - ], - "fixes": [ - "Fixed OAuth connect flows that could get stuck if you canceled partway through.", - "Fixed \"Open in tracker\" links that weren't opening your browser.", - "Daily plan reminders no longer stack up past midnight." + "items": [ + { + "title": "Six trackers supported", + "body": "GitHub, Linear, Azure DevOps, Jira, Trello and Asana." + }, + { + "title": "Native GitHub sign-in", + "body": "No more copy-pasting a command-line login." + }, + { + "title": "A live health panel", + "body": "Tap the Capturing status in the popover to see it." + } ] }, { "version": "1.69.1", "date": "2026-07-05", - "highlights": [], - "fixes": [ - "Fixed external links not opening from inside the app." + "items": [ + { + "title": "External links open again", + "body": "Links from inside the app now reach your browser." + } ] }, { "version": "1.69.0", "date": "2026-07-03", - "highlights": [ - "Brand-new one-pager Timeline dashboard design.", - "Added the Meridian nav pill to the toolbar for quicker navigation.", - "Broader ticket write-back support across Jira, Linear, and Azure DevOps.", - "Rebuilt the popover to match the latest design." - ], - "fixes": [ - "Fixed a leak that could grow the local AI server's memory use by several GB an hour.", - "Fixed a bug that could post the same work log to Jira twice." + "items": [ + { + "title": "A one-pager Timeline dashboard", + "body": "A brand-new design that puts the whole day on one screen." + }, + { + "title": "Ticket write-back everywhere", + "body": "Broader support across Jira, Linear and Azure DevOps." + } ] } ], @@ -437,17 +467,17 @@ { "title": "Track down the last cause of database damage on Mac", "status": "in-progress", - "description": "Four causes are now fixed: a risky one-time encryption step, running out of file handles, Meridian restarting its own background service when it was only busy, and this release closing the gaps where the app and the background service could both write to your data at the same time. Damage has still been seen after the first three, so we are not calling this solved until the machines we are watching stay clean. What has changed is the recovery: Meridian now repairs a damaged database by itself at startup, so a bad one no longer means a dead app." + "description": "Four causes are fixed and Meridian now repairs a damaged database by itself at startup, but we won't call this solved until the machines we're watching stay clean." }, { - "title": "More trackers - Linear, Trello, and Azure DevOps", + "title": "More trackers - Linear, Trello and Azure DevOps", "status": "planned", - "description": "Meridian already speaks to all three. They are listed as coming soon while we finish testing each one end to end, so the trackers you can connect today are ones we know work." + "description": "Meridian already speaks to all three; they stay listed as coming soon until each one is tested end to end." }, { "title": "Report crashes in the background service", "status": "planned", - "description": "Error reporting currently covers the app itself. Extending it to the background service means a hard crash there gets reported too, rather than only being visible in local logs." + "description": "Error reporting covers the app today, so a hard crash in the background service is only visible in local logs." } ] } diff --git a/tray/src-tauri/src/commands/whats_new.rs b/tray/src-tauri/src/commands/whats_new.rs index 104d58ee2..4414e6307 100644 --- a/tray/src-tauri/src/commands/whats_new.rs +++ b/tray/src-tauri/src/commands/whats_new.rs @@ -21,13 +21,24 @@ use serde::{Deserialize, Serialize}; use std::path::Path; use std::sync::OnceLock; -/// One release's user-facing notes. +/// One entry in a release: a short headline plus a single sentence. The +/// deliberate shape of the whole feature — a release is at most two or three +/// of these, so the modal can be read at a glance rather than skimmed and +/// abandoned. Anything that needs a paragraph belongs in the docs, not here. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ReleaseItem { + pub title: String, + pub body: String, +} + +/// One release's user-facing notes. No highlights/fixes split — a user reading +/// this doesn't care which bucket a change came from, and the split doubled +/// the length of every entry. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ReleaseNote { pub version: String, pub date: String, - pub highlights: Vec, - pub fixes: Vec, + pub items: Vec, } /// A roadmap item's status. A serde enum (vs. a bare `String`) so a typo'd @@ -136,6 +147,37 @@ mod tests { assert!(!data.releases.is_empty(), "seed at least one release"); } + /// The brevity rule, enforced rather than trusted: What's New is read at a + /// glance or not at all, so a release gets at most three entries and each + /// body stays one short sentence. Without this the file drifts back into + /// pasted commit messages within a few releases. + #[test] + fn release_notes_stay_short() { + let data: WhatsNewData = serde_json::from_str(WHATS_NEW_JSON).unwrap(); + for r in &data.releases { + assert!( + !r.items.is_empty() && r.items.len() <= 3, + "v{}: 1-3 items, got {}", + r.version, + r.items.len() + ); + for it in &r.items { + assert!( + it.title.len() <= 44, + "v{}: title too long: {:?}", + r.version, + it.title + ); + assert!( + it.body.len() <= 160, + "v{}: body must be one short sentence: {:?}", + r.version, + it.body + ); + } + } + } + #[test] fn unseen_when_marker_absent() { let dir = diff --git a/ui/components/timeline/WhatsNewModal.tsx b/ui/components/timeline/WhatsNewModal.tsx index 6a56df042..493b30fed 100644 --- a/ui/components/timeline/WhatsNewModal.tsx +++ b/ui/components/timeline/WhatsNewModal.tsx @@ -6,6 +6,11 @@ // (releases newest-first, from `get_whats_new`) and Roadmap. `scrollInside` // gives the tab bar a fixed home while the content below scrolls on its own, // same layout technique SettingsModal uses for its sidebar + content split. +// +// A release renders as at most three title + one-sentence entries, with no +// highlights/fixes split: the previous bulleted paragraphs were too long to +// actually get read, and the bucket a change came from is our concern, not +// the reader's. The brevity is enforced in `whats_new.rs`, not just curated. 'use client' @@ -106,18 +111,14 @@ function ReleaseList({ releases }: { releases: ReleaseNote[] }) { {latest && }

{r.date}

- {r.highlights.length > 0 && ( - - )} - {r.fixes.length > 0 && ( - <> -
- -

FIXES

+
+ {r.items.map(item => ( +
+

{item.title}

+

{item.body}

- - - )} + ))} +
) })} @@ -140,20 +141,6 @@ function LatestBadge() { ) } -function BulletList({ items, dotColor }: { items: string[]; dotColor: string }) { - return ( -
    - {items.map((item, i) => ( -
  • -
  • - ))} -
- ) -} - function RoadmapList({ items }: { items: RoadmapItem[] }) { return (
diff --git a/ui/lib/api-types.ts b/ui/lib/api-types.ts index 77c1ad13a..26f14647e 100644 --- a/ui/lib/api-types.ts +++ b/ui/lib/api-types.ts @@ -798,11 +798,16 @@ export interface UpdateError { // ── What's New (`get_whats_new`) ─────────────────────────────────────────────── +/** A headline plus one sentence — see `whats_new.rs`'s `ReleaseItem`. */ +export interface ReleaseItem { + title: string + body: string +} + export interface ReleaseNote { version: string date: string - highlights: string[] - fixes: string[] + items: ReleaseItem[] } export interface RoadmapItem { From 790954a6f837eb7f33424e6a41f869ee1fb20b29 Mon Sep 17 00:00:00 2001 From: Akarsh Hegde Date: Tue, 25 Aug 2026 21:03:38 +0530 Subject: [PATCH 03/53] fix(tray): repaint the offline banner as soon as the daemon+DB are ready The poll loop's health tick only runs every 60s (ticks 0, 2, 4...), and tick 0 fires at launch - before the daemon has necessarily finished starting or the DB pool has opened. A cold start correctly reports Unhealthy at that instant, then the popover's "Meridian is offline" banner sat there stale for up to 60s even once the daemon and DB were both actually ready, because nothing repainted the displayed status in between. Observed live: the health panel's on-demand rows already showed Daemon Running / Database Ready while the banner above them still said offline. Adds a separate one-shot fast-poll (3s interval, 60s ceiling) that repaints AppState.health/ui_reachable and re-emits status-update the moment the daemon+DB are ready, then stops permanently. It shares the same is_healthy() predicate as the poll loop's own health tick (now factored out) so the two can never disagree about what "healthy" means, and it deliberately never touches the went-quiet notice or auto-restart decision state - those stay solely owned by the slower, debounced refresh_health tick. --- tray/src-tauri/src/commands/health.rs | 65 ++++++++++++++++++ tray/src-tauri/src/lib.rs | 14 ++++ tray/src-tauri/src/poll/mod.rs | 6 ++ tray/src-tauri/src/poll/refresh.rs | 6 +- tray/src-tauri/src/poll/startup_health.rs | 82 +++++++++++++++++++++++ 5 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 tray/src-tauri/src/poll/startup_health.rs diff --git a/tray/src-tauri/src/commands/health.rs b/tray/src-tauri/src/commands/health.rs index fedb4043d..62d3c58a2 100644 --- a/tray/src-tauri/src/commands/health.rs +++ b/tray/src-tauri/src/commands/health.rs @@ -50,6 +50,22 @@ pub struct HealthResponse { pub llm_provider_detail: Option, } +/// Whether a [`HealthResponse`] counts as a healthy tray: the DB is open and +/// the daemon process is running (the two signals the popover's +/// online/offline banner is actually gated on — LLM-provider availability is +/// a separate, softer banner and does not factor in here). +/// +/// Shared between the poll loop's notice-owning +/// [`crate::poll::refresh_health`] and the startup fast-poll +/// ([`crate::poll::startup_health`]) so the two can never disagree about what +/// "healthy" means — this is the one place that decides it. +/// +/// `database_ready` defaults to unhealthy when unknown; `daemon_running` +/// defaults to healthy (older schema compat). +pub fn is_healthy(hr: &HealthResponse) -> bool { + hr.database_ready.unwrap_or(false) && hr.daemon_running.unwrap_or(true) +} + /// Run all three health checks in parallel and return the combined result. /// Called by both `get_health` (Tauri command) and `poll::refresh_health` (internal). pub async fn check_health() -> HealthResponse { @@ -248,3 +264,52 @@ pub async fn get_health() -> Result { ); Ok(result) } + +#[cfg(test)] +mod tests { + use super::*; + + fn hr(database_ready: Option, daemon_running: Option) -> HealthResponse { + HealthResponse { + database_ready, + daemon_running, + a11y_helper_trusted: None, + error: None, + llm_provider_ok: None, + llm_provider_rate_limited: None, + llm_provider_name: None, + llm_provider_detail: None, + } + } + + #[test] + fn healthy_when_both_signals_are_true() { + assert!(is_healthy(&hr(Some(true), Some(true)))); + } + + #[test] + fn unhealthy_when_the_db_is_not_ready() { + assert!(!is_healthy(&hr(Some(false), Some(true)))); + } + + #[test] + fn unhealthy_when_the_daemon_is_not_running() { + assert!(!is_healthy(&hr(Some(true), Some(false)))); + } + + /// `database_ready: None` must read as unhealthy - an unknown DB state is + /// never treated as "fine". Older-schema compat only applies to + /// `daemon_running` (below), not this field. + #[test] + fn an_unknown_db_state_is_treated_as_unhealthy() { + assert!(!is_healthy(&hr(None, Some(true)))); + } + + /// `daemon_running: None` defaults to healthy (older schema compat) - the + /// mirror image of the DB default, and the one place the two signals + /// disagree on how to treat "unknown". + #[test] + fn an_unknown_daemon_state_defaults_to_healthy() { + assert!(is_healthy(&hr(Some(true), None))); + } +} diff --git a/tray/src-tauri/src/lib.rs b/tray/src-tauri/src/lib.rs index dc134f10d..d2a83eb38 100644 --- a/tray/src-tauri/src/lib.rs +++ b/tray/src-tauri/src/lib.rs @@ -1188,6 +1188,20 @@ pub fn run() { poll::run_daemon_watchdog().await; }); + // One-shot: repaint the popover's online/offline banner the moment + // the daemon+DB are actually ready, instead of leaving it stuck on + // "Meridian is offline" until the poll loop's own next 30/60 s + // health tick. Separate from both loops above on purpose — see + // `poll::startup_health` for why it must not touch either one's + // state. + { + let app_handle = app.handle().clone(); + let state_clone = app_state.clone(); + tauri::async_runtime::spawn(async move { + poll::fast_poll_until_healthy(app_handle, state_clone).await; + }); + } + // Launch-at-login AND morning relaunch: VERIFY-AND-REPAIR on every // launch (see autostart.rs), so the tray comes back after a reboot // and again the next morning if it was quit. Deliberately not diff --git a/tray/src-tauri/src/poll/mod.rs b/tray/src-tauri/src/poll/mod.rs index 938218519..832a1fc09 100644 --- a/tray/src-tauri/src/poll/mod.rs +++ b/tray/src-tauri/src/poll/mod.rs @@ -12,6 +12,10 @@ //! server-side per row). //! - [`live`] — the live data → Tauri events that replace the dashboard's SSE //! streams: `notices-update`, `notifications-update`. +//! - [`startup_health`] — a separate, faster one-shot loop (spawned +//! alongside this one, not called from inside it) that repaints the +//! displayed health status the moment the daemon+DB are ready, instead of +//! waiting for this loop's next 30/60 s-cadenced health tick. //! //! The tray-sync helpers (emit / tooltip / menu) stay here, coupled to the loop. @@ -20,6 +24,7 @@ mod notifications; mod permissions; mod plan_auto_open; mod refresh; +mod startup_health; mod watchdog; mod whats_new_auto_open; @@ -29,6 +34,7 @@ use notifications::drain_notifications; use refresh::{ refresh_active, refresh_current_task, refresh_health, refresh_today, refresh_worklogs, }; +pub use startup_health::fast_poll_until_healthy; use std::sync::atomic::Ordering; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; diff --git a/tray/src-tauri/src/poll/refresh.rs b/tray/src-tauri/src/poll/refresh.rs index 50ddccee4..eaaebcead 100644 --- a/tray/src-tauri/src/poll/refresh.rs +++ b/tray/src-tauri/src/poll/refresh.rs @@ -52,11 +52,13 @@ pub(super) async fn refresh_health( // (it also carries `daemon_running`, which the banner ignores). let _ = app.emit("health-update", &hr); - // db_ready and daemon_running both default true when absent (older schema compat). + // db_ready defaults unhealthy when unknown; daemon_running defaults healthy + // (older schema compat) — kept as locals for the log fields below, but the + // classification itself goes through `is_healthy` (see there for why). let db_ready = hr.database_ready.unwrap_or(false); let daemon_running = hr.daemon_running.unwrap_or(true); - let new_health = if db_ready && daemon_running { + let new_health = if crate::commands::health::is_healthy(&hr) { HealthStatus::Healthy } else { HealthStatus::Unhealthy diff --git a/tray/src-tauri/src/poll/startup_health.rs b/tray/src-tauri/src/poll/startup_health.rs new file mode 100644 index 000000000..fcd166831 --- /dev/null +++ b/tray/src-tauri/src/poll/startup_health.rs @@ -0,0 +1,82 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +//! A one-shot fast health repaint for the first seconds after tray launch. +//! +//! # The problem this closes +//! The poll loop's [`super::refresh::refresh_health`] only runs on ticks +//! 0, 2, 4, … — every 60 s — because that cadence also drives the went-quiet +//! / back-online *notice* and the auto-restart decision +//! (`decide_health_notice`), both deliberately debounced so a genuine outage +//! doesn't flap. Tick 0 fires within moments of launch, before the daemon has +//! necessarily finished starting or the DB pool has opened, so a cold start +//! correctly reports Unhealthy at that instant — and then the popover's +//! "Meridian is offline" banner just sits there, stale, for up to 60 s even +//! once the daemon and DB are both actually ready, because nothing repaints +//! the DISPLAYED status in between. Observed live: the health panel's +//! on-demand rows ("Daemon: Running", "Database: Ready") already show the +//! true state the moment it opens, while the banner above it still says +//! offline — two reads of the same underlying signal, one fresh, one stale. +//! +//! # What this does and does not touch +//! This loop only repaints [`AppState::health`] / `ui_reachable` and re-emits +//! `status-update` — it never touches `consecutive_health_failures`, +//! `daemon_was_healthy` or `startup_health_reconciled`, so it cannot affect +//! the went-quiet notice or trigger an auto-restart; those stay solely owned +//! by `refresh_health`. It runs at most once per process, stops the instant +//! it observes a healthy check, and gives up silently after [`CEILING`], +//! letting the normal cadence take over — it must never spin forever on a +//! machine that is genuinely down. +//! +//! It also does not change what "healthy" MEANS — it shares +//! [`crate::commands::health::is_healthy`] with `refresh_health`, so the two +//! can never disagree, and this fix is scoped to the display lag only, not a +//! new readiness signal. +//! +//! # Related +//! - [`super::refresh::refresh_health`] — the slower, notice-owning check +//! this repaints ahead of. +//! - [`super::watchdog`] — the same "separate fast loop, narrow job" shape, +//! for starting a stopped daemon rather than painting the UI. + +use crate::commands::health::{check_health, is_healthy}; +use crate::state::{AppState, HealthStatus}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tauri::Emitter; + +/// How often to recheck while not yet healthy. +const TICK: Duration = Duration::from_secs(3); + +/// Give up after this long. Matches the worst-case wait this loop replaces +/// (the normal cadence's 60 s), so a genuinely broken install is never worse +/// off than before this loop existed — just never better, past this point. +const CEILING: Duration = Duration::from_secs(60); + +/// Poll [`check_health`] every [`TICK`] until the tray is healthy (or +/// [`CEILING`] elapses), repainting the displayed status the moment it is. +/// Intended to be spawned once, at tray startup, alongside the main poll +/// loop — see `lib.rs`. +pub async fn fast_poll_until_healthy(app: tauri::AppHandle, state: Arc>) { + let deadline = tokio::time::Instant::now() + CEILING; + loop { + let hr = check_health().await; + if is_healthy(&hr) { + let payload = { + let Ok(mut s) = state.lock() else { + return; + }; + // A concurrent `refresh_health` tick may already have painted + // this by the time we get here — overwriting it with the same + // (now also fresh) verdict is harmless. + s.health = HealthStatus::Healthy; + s.ui_reachable = true; + s.to_payload() + }; + let _ = app.emit("status-update", payload); + return; + } + if tokio::time::Instant::now() >= deadline { + return; + } + tokio::time::sleep(TICK).await; + } +} From 94f79825428acabb4ac941fa83f64052060213a6 Mon Sep 17 00:00:00 2001 From: Akarsh Hegde Date: Tue, 25 Aug 2026 21:24:07 +0530 Subject: [PATCH 04/53] fix(llm): show an actionable message when a sign-in CLI crashes at Node startup codex/claude/cursor-agent are all #!/usr/bin/env node scripts, and when Node crashes while loading the entrypoint itself (not the CLI's own logic - the stack trace names internal/modules/esm and ends in a bare "Node.js vX.Y.Z" line) interactive_login dumped that raw multi-line internal trace verbatim into the Settings UI as the failure message, e.g. "codex exited Some(1): ///opt/homebrew/lib/node_modules/@openai/codex/bin/codex.js:105:9)...at ModuleJob.run (node:internal/modules/esm/module_job:437:25)...Node.js v25.9.0" - unreadable and not actionable for a user. node_crash_message() recognizes this exact signature and swaps it for plain copy pointing at the real, actionable fix (update the CLI or switch Node versions). Anything not matching the signature falls through to the existing raw-tail message unchanged, so no other failure reason is masked. This is a pure message-formatting change - it does not touch how the CLI is resolved or spawned (command_for_resolved_cli's PATH construction is unrelated and untouched), so it carries no risk to the sign-in flow itself regardless of what is actually causing the underlying Node crash on the reporting machine. --- src/llm/detect.rs | 82 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 73 insertions(+), 9 deletions(-) diff --git a/src/llm/detect.rs b/src/llm/detect.rs index 946ac14a6..dbf3df0cb 100644 --- a/src/llm/detect.rs +++ b/src/llm/detect.rs @@ -1154,6 +1154,33 @@ fn buffered_tail(buf: &std::sync::Mutex, n: usize) -> String { tail(&s, n) } +/// Recognize a Node.js uncaught-exception crash during the CLI's own module +/// load (as opposed to an auth failure the CLI reported cleanly) and swap the +/// raw, multi-line internal stack trace for plain-language copy. +/// +/// `codex`/`claude`/`cursor-agent` are all `#!/usr/bin/env node` scripts, +/// and a crash while Node is still loading the entrypoint - not running its +/// own logic - always ends its report with a bare `Node.js vX.Y.Z` line and +/// names `internal/modules/esm`/`ModuleJob.run` in the stack. That signature +/// is what was dumped verbatim into Settings: `codex exited Some(1): +/// .../codex.js:105:9) ... at ModuleJob.run (node:internal/modules/esm/ +/// module_job:437:25) ... Node.js v25.9.0`. Anything not matching this exact +/// shape returns `None` and the caller falls back to the raw tail unchanged - +/// this only replaces a message we can say something more specific about, it +/// never hides one. +fn node_crash_message(bin: &str, stderr_tail: &str) -> Option { + if stderr_tail.contains("Node.js v") + && (stderr_tail.contains("internal/modules/esm") || stderr_tail.contains("ModuleJob.run")) + { + return Some(format!( + "{bin} crashed on startup instead of signing in - this usually means the installed \ + {bin} isn't compatible with your current Node.js version. Try updating {bin} to its \ + latest version, or switching to a different Node.js version, then sign in again." + )); + } + None +} + /// [`interactive_login`]'s three durations, bundled so a real sign-in and a test can each /// supply their own without growing the function's argument count. Production always uses /// [`Self::PRODUCTION`]; tests use tiny values so the same race logic exercises in @@ -1324,15 +1351,20 @@ where } else { stderr_tail }; - let process_message = format!( - "{bin} exited {:?}: {}", - status.code(), - if reason.is_empty() { - "no output".to_string() - } else { - reason - } - ); + let process_message = if let Some(friendly) = node_crash_message(bin, &reason) { + tracing::debug!(bin, raw_tail = %reason, "sign-in CLI crashed at Node module load, showing a friendlier message"); + friendly + } else { + format!( + "{bin} exited {:?}: {}", + status.code(), + if reason.is_empty() { + "no output".to_string() + } else { + reason + } + ) + }; confirm_or_report(label, bin, &path, success_message, &verify, process_message).await } Ended::WaitError(e) => { @@ -1802,6 +1834,38 @@ fn path_candidate_names(bin: &str) -> Vec { mod tests { use super::*; + const NODE_ESM_CRASH_TAIL: &str = "///opt/homebrew/lib/node_modules/@openai/codex/bin/codex.js:105:9)\n at ModuleJob.run (node:internal/modules/esm/module_job:437:25)\n at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:639:26)\n at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)\n\nNode.js v25.9.0"; + + /// The exact signature from the reported crash: a friendlier message replaces the raw + /// Node internal stack trace. + #[test] + fn recognizes_a_node_esm_loader_crash() { + let msg = node_crash_message("codex", NODE_ESM_CRASH_TAIL).expect("should recognize crash"); + assert!(msg.contains("codex")); + assert!(msg.contains("Node.js version")); + assert!( + !msg.contains("ModuleJob.run"), + "raw stack trace must not leak into the friendly message" + ); + } + + /// An ordinary auth failure (no Node crash signature) must fall through unchanged - this + /// must never mask a real, different failure reason. + #[test] + fn leaves_an_unrelated_failure_untouched() { + assert!( + node_crash_message("codex", "Error: invalid device code, please try again").is_none() + ); + assert!(node_crash_message("codex", "").is_none()); + } + + /// "Node.js v" alone (e.g. a version banner some CLIs print on success) is not enough by + /// itself - only the module-loader signature counts as a crash. + #[test] + fn requires_the_module_loader_signature_not_just_a_node_version_string() { + assert!(node_crash_message("codex", "codex 1.2.3 (Node.js v22.1.0)").is_none()); + } + fn groq_custom_provider() -> meridian_core::settings::CustomLlmProvider { meridian_core::settings::CustomLlmProvider { id: "groq1".to_string(), From d92131eecac588c51b08e58274cd30591be17d61 Mon Sep 17 00:00:00 2001 From: Akarsh Hegde Date: Tue, 25 Aug 2026 21:31:17 +0530 Subject: [PATCH 05/53] fix(llm): shell-quote resolved paths in the installer command builder Surfaced by this branch's pre-push security audit: resolve_installer_binary interpolated a locally-resolved CLI path (and its parent directory) directly into POSIX shell text without quoting. install_command()'s own literals are fixed per-provider strings and already documented as safe to run unquoted, but the resolved path comes from a filesystem PATH lookup (resolve_cli) - an attacker able to plant a maliciously-named file in a writable PATH directory could otherwise inject shell syntax into the command this builds. shell_quote() POSIX single-quotes both interpolated values (escaping any embedded single quote), so the substituted text can never break out of its own quoting regardless of what characters the resolved path contains. --- src/llm/detect.rs | 43 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/src/llm/detect.rs b/src/llm/detect.rs index dbf3df0cb..3444a9a89 100644 --- a/src/llm/detect.rs +++ b/src/llm/detect.rs @@ -785,6 +785,17 @@ pub struct InstallOutcome { /// This was a live bug: it made the Install button fail for every npm-based provider on /// Windows, and - because the leading token is what triggers the rewrite - Cursor was /// unaffected purely by accident, its command starting with `$s`. +/// POSIX single-quote a string for safe interpolation into the shell text +/// [`resolve_installer_binary`] builds. `dir`/`resolved` come from a local +/// filesystem path resolution (`resolve_cli`), not a fixed literal like +/// `rest` - an attacker able to plant a maliciously-named file in a PATH +/// directory could otherwise inject shell syntax into the installer command +/// this builds. +#[cfg(not(windows))] +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', r"'\''")) +} + #[cfg(windows)] async fn resolve_installer_binary(cmd: &str) -> String { cmd.to_string() @@ -806,11 +817,11 @@ async fn resolve_installer_binary(cmd: &str) -> String { // npm itself fixed the first one. Some(resolved) => match resolved.parent() { Some(dir) => format!( - "export PATH=\"{}:$PATH\"; {} {rest}", - dir.display(), - resolved.display() + "export PATH={}:$PATH; {} {rest}", + shell_quote(&dir.display().to_string()), + shell_quote(&resolved.display().to_string()) ), - None => format!("{} {rest}", resolved.display()), + None => format!("{} {rest}", shell_quote(&resolved.display().to_string())), }, None => cmd.to_string(), }, @@ -1866,6 +1877,24 @@ mod tests { assert!(node_crash_message("codex", "codex 1.2.3 (Node.js v22.1.0)").is_none()); } + #[cfg(not(windows))] + #[test] + fn shell_quote_wraps_an_ordinary_path_unchanged() { + assert_eq!(shell_quote("/opt/homebrew/bin"), "'/opt/homebrew/bin'"); + } + + /// The finding this exists to close: an embedded single quote must not let the string + /// escape its own quoting and inject shell syntax. + #[cfg(not(windows))] + #[test] + fn shell_quote_escapes_an_embedded_single_quote() { + let malicious = "/tmp/evil'; rm -rf ~; echo '"; + let quoted = shell_quote(malicious); + // Every character of the input must appear only inside a quoted segment - + // reassembling the escape sequence proves it round-trips back to the original. + assert_eq!(quoted, r"'/tmp/evil'\''; rm -rf ~; echo '\'''"); + } + fn groq_custom_provider() -> meridian_core::settings::CustomLlmProvider { meridian_core::settings::CustomLlmProvider { id: "groq1".to_string(), @@ -2909,9 +2938,9 @@ mod tests { assert_eq!( resolved, format!( - "export PATH=\"{}:$PATH\"; {} i -g some-package", - bin_dir.display(), - fake_bin.display() + "export PATH={}:$PATH; {} i -g some-package", + shell_quote(&bin_dir.display().to_string()), + shell_quote(&fake_bin.display().to_string()) ), "expected the leading binary swapped for its resolved absolute path, with its \ directory prepended onto PATH" From 31730fba1e46f76e8b5815e2f9e5a3acc363cffe Mon Sep 17 00:00:00 2001 From: Akarsh Hegde Date: Tue, 25 Aug 2026 22:06:47 +0530 Subject: [PATCH 06/53] fix(coding-agent): move the summariser's codex/claude prompt off argv onto stdin Root-caused via a diagnostics bundle attached to github.com/Meridiona/meridian/issues/805: the coding-agent session summariser (src/coding_agent_session_ingest/summariser/{codex,claude}.rs) was passing its full instructions prompt - always multi-line, sourced from assets/skills/coding-agent/session-summary/SKILL.md - as a positional argv argument to `codex exec`/`claude -p`. On Windows both resolve to npm-generated .cmd batch files, and Rust's std library refuses to spawn a .bat/.cmd target when an argument contains a character it cannot safely escape - notably an embedded newline (the CVE-2024-24576 "BatBadBut" fix) - so every real call through this path failed to even spawn, with `io::Error { InvalidInput, "batch file arguments are invalid" }`. The attached bundle shows this firing 165 times across dozens of session rows on one Windows machine, each one retried once and then dead-lettered ("summarise failed repeatedly"). This is the exact bug `src/llm/{codex,claude}.rs` (the hourly worklog pipeline / connectivity-test backends) already found and fixed in a2a31c19 (2026-07-24) by moving the prompt to stdin - but that fix never reached its sibling in the coding-agent summariser, which has carried the identical bug since it was written. This applies the same fix here: no positional prompt on argv, the instructions + transcript combined into one stdin payload instead (matching each engine's already-fixed stdin shape exactly - codex's `` wrapping, claude's bare join). Codex's schema is also now run through `crate::llm::schema::strictify` before being written, matching `CodexBackend`'s already-fixed path, since unblocking the spawn means codex's own strict-schema validation is now reachable too. `summariser/copilot.rs` has the same npm-.cmd exposure on Windows but can't take the same fix (`copilot -p` ignores stdin, confirmed live) - left as a documented known gap, not fixed here. This closes the "batch file arguments are invalid" class of failure (github.com/Meridiona/meridian/issues/805, #841's "failed to write any tasks/summaries" symptom) but NOT #805's separate `test_llm_provider` 20s-timeout symptom (a different code path, `src/llm/codex.rs::signed_out`'s slow-fail case - not touched here) or #841's Claude-session-drops-out symptom (macOS, so this Windows-only argv bug cannot be the cause - needs separate investigation). New tests in both files pin: the instructions prompt is genuinely multi-line (the premise the fix rests on), argv never carries a newline, no positional prompt is passed, the model flag is threaded correctly, and the stdin payload carries both the instructions and the transcript. --- .../summariser/claude.rs | 114 ++++++++++-- .../summariser/codex.rs | 166 +++++++++++++++--- .../summariser/copilot.rs | 10 ++ .../summariser/prompts.rs | 18 +- 4 files changed, 261 insertions(+), 47 deletions(-) diff --git a/src/coding_agent_session_ingest/summariser/claude.rs b/src/coding_agent_session_ingest/summariser/claude.rs index 10f964856..f76c0e3cd 100644 --- a/src/coding_agent_session_ingest/summariser/claude.rs +++ b/src/coding_agent_session_ingest/summariser/claude.rs @@ -14,6 +14,21 @@ // spawns. `--no-session-persistence` means no JSONL is written for it either. // NOTE: the inherited env must carry HOME/PATH/USER/LOGNAME for the login // keychain to unlock (see the auth spike) — the daemon's launchd plist owns that. +// +// # The whole prompt goes over stdin, not argv (Windows) +// +// `claude` resolves to `claude.cmd` on Windows for an npm install (`npm i -g +// @anthropic-ai/claude-code`, the exact command `install_command()` runs for this +// provider) - an npm-generated batch file, not a native exe - and Rust's std library +// refuses to spawn a `.bat`/`.cmd` target when an argument contains characters it +// cannot safely escape (the CVE-2024-24576 "BatBadBut" fix), notably embedded +// newlines. SUMMARY_RULES is sourced from a Markdown rules file, so it is always +// multi-line - meaning every real call through this function failed to even spawn +// on Windows with `io::Error { InvalidInput, "batch file arguments are invalid" }`. +// `crate::llm::claude::ClaudeBackend` (the hourly worklog pipeline / connectivity +// test) already moved off argv for exactly this reason - this applies the same fix +// here, which had the identical bug all along. `-p` as a bare flag (no positional +// value) reads the whole prompt from stdin instead. use serde_json::Value; @@ -21,6 +36,32 @@ use super::config::SummariserConfig; use super::prompts; use super::{run_capture, EngineOutput, SummariserError}; +/// The instructions + the session transcript, combined into the one blob `claude -p` +/// reads from stdin now that no positional prompt is passed - see the module doc. +fn claude_stdin_payload(stdin_text: &str) -> String { + let instructions = format!( + "{} Summarise the coding-session transcript provided on stdin.", + prompts::SUMMARY_RULES + ); + format!("{instructions}\n\n{stdin_text}") +} + +/// The `claude -p` argv - no prompt in here, see the module doc. Split out so the +/// no-newline invariant this function exists to guarantee is directly testable. +fn claude_args(model: &str) -> Vec { + vec![ + "-p".into(), + "--output-format".into(), + "json".into(), + "--json-schema".into(), + prompts::summary_schema_json(), + "--model".into(), + model.to_string(), + "--no-session-persistence".into(), + "--strict-mcp-config".into(), // drop MCP overhead; keeps skills working + ] +} + pub async fn run_claude( stdin_text: &str, cfg: &SummariserConfig, @@ -35,27 +76,12 @@ pub async fn run_claude( the env var has no effect and can be removed" ); } - let prompt = format!( - "{} Summarise the coding-session transcript provided on stdin.", - prompts::SUMMARY_RULES - ); - let args: Vec = vec![ - "-p".into(), - prompt, - "--output-format".into(), - "json".into(), - "--json-schema".into(), - prompts::summary_schema_json(), - "--model".into(), - cfg.claude_model.clone(), - "--no-session-persistence".into(), - "--strict-mcp-config".into(), // drop MCP overhead; keeps skills working - ]; + let args = claude_args(&cfg.claude_model); let cap = run_capture( "claude", &args, - stdin_text, + &claude_stdin_payload(stdin_text), &cfg.meridian_home, cfg.claude_timeout_s, &[("MERIDIAN_SUMMARISER", "1")], @@ -130,3 +156,57 @@ pub async fn run_claude( } Ok(EngineOutput { summary }) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The premise the whole fix rests on: SUMMARY_RULES is sourced from a Markdown + /// rules file and is always multi-line. If `SKILL.md` ever became single-line, this + /// fix would no longer be guarding against anything real - this pins that it still is. + #[test] + fn summary_rules_is_multi_line() { + assert!(prompts::SUMMARY_RULES.contains('\n')); + } + + /// The regression this fix exists to close: `claude -p` argv must never carry the + /// instructions prompt (or anything else newline-bearing) - that is exactly what + /// makes Rust's std refuse to spawn `claude.cmd` on Windows. A future edit that + /// reintroduces the prompt into `args` fails this test. + #[test] + fn claude_args_never_contains_a_newline() { + let args = claude_args("claude-opus-5"); + for arg in &args { + assert!(!arg.contains('\n'), "argv entry carries a newline: {arg:?}"); + } + } + + /// `claude -p` must be a bare flag - a positional value right after it would force + /// the prompt back through argv. + #[test] + fn claude_args_has_no_positional_prompt() { + let args = claude_args("claude-opus-5"); + assert_eq!(args[0], "-p"); + assert_eq!( + args[1], "--output-format", + "the second argv entry must be a flag, not a prompt" + ); + } + + #[test] + fn claude_args_carries_the_model() { + let args = claude_args("claude-opus-5"); + let i = args + .iter() + .position(|a| a == "--model") + .expect("--model flag present"); + assert_eq!(args[i + 1], "claude-opus-5"); + } + + #[test] + fn claude_stdin_payload_carries_both_the_instructions_and_the_transcript() { + let payload = claude_stdin_payload("the transcript"); + assert!(payload.contains("the transcript")); + assert!(payload.starts_with(&prompts::SUMMARY_RULES[..40])); + } +} diff --git a/src/coding_agent_session_ingest/summariser/codex.rs b/src/coding_agent_session_ingest/summariser/codex.rs index 6a101005b..ac35b00e8 100644 --- a/src/coding_agent_session_ingest/summariser/codex.rs +++ b/src/coding_agent_session_ingest/summariser/codex.rs @@ -5,6 +5,20 @@ // `--ephemeral` (no session file → indexer won't re-pick it), `--output-schema` // + `-o FILE` to capture the structured final message. Port of // the former Python summariser/codex_runner.py. +// +// # The whole prompt goes over stdin, not argv (Windows) +// +// `codex` resolves to `codex.cmd` on Windows - an npm-generated batch file, not a +// native exe - and Rust's std library refuses to spawn a `.bat`/`.cmd` target when an +// argument contains characters it cannot safely escape (the CVE-2024-24576 "BatBadBut" +// fix), notably embedded newlines. The instructions prompt is sourced from a Markdown +// rules file (`SUMMARY_RULES`), so it is always multi-line - meaning every real call +// through this function failed to even spawn on Windows with `io::Error { InvalidInput, +// "batch file arguments are invalid" }` (confirmed live: +// github.com/Meridiona/meridian/issues/805). `crate::llm::codex::CodexBackend` (the +// hourly worklog pipeline / connectivity test) already moved off argv for exactly this +// reason - this applies the same fix here, which had the identical bug all along. +// `codex exec` with no positional prompt reads the whole prompt from stdin instead. use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; @@ -13,15 +27,48 @@ use super::config::SummariserConfig; use super::prompts; use super::{run_capture, EngineOutput, SummariserError}; -pub async fn run_codex( - stdin_text: &str, - cfg: &SummariserConfig, -) -> Result { - let prompt = format!( +/// The instructions + the session transcript, combined into the one blob `codex exec` +/// reads from stdin now that no positional prompt is passed - see the module doc. +fn codex_stdin_payload(stdin_text: &str) -> String { + let instructions = format!( "{} Summarise the coding-session transcript provided on stdin.", prompts::summary_instruction() ); + format!("{instructions}\n\n\n{stdin_text}\n\n") +} +/// The `codex exec` argv - no prompt in here, see the module doc. Split out so the +/// no-newline invariant this function exists to guarantee is directly testable. +fn codex_args( + schema_path: &std::path::Path, + out_path: &std::path::Path, + home: String, + model: &str, +) -> Vec { + let mut args: Vec = vec![ + "exec".into(), + "-s".into(), + "read-only".into(), + "--skip-git-repo-check".into(), + "--ephemeral".into(), + "--output-schema".into(), + schema_path.display().to_string(), + "-o".into(), + out_path.display().to_string(), + "-C".into(), + home, + ]; + if !model.is_empty() { + args.push("-m".into()); + args.push(model.to_string()); + } + args +} + +pub async fn run_codex( + stdin_text: &str, + cfg: &SummariserConfig, +) -> Result { // Unique scratch dir for the schema + captured final message. Avoids the // time/random APIs (banned in some contexts) via pid + a static counter. static SEQ: AtomicU64 = AtomicU64::new(0); @@ -36,34 +83,22 @@ pub async fn run_codex( let _guard = TempDirGuard(td.clone()); let schema_path = td.join("schema.json"); let out_path = td.join("last_message.txt"); - if let Err(e) = std::fs::write(&schema_path, prompts::summary_schema_json()) { + // `strictify`: matches `crate::llm::codex::CodexBackend`, which applies the same + // transform before handing a schema to codex - without it, codex's own strict + // dialect check can reject the schema with `invalid_json_schema` (see that + // module's test fixture, a live 400 from exactly this). + let schema = crate::llm::schema::strictify(&prompts::summary_schema_value()); + if let Err(e) = std::fs::write(&schema_path, schema.to_string()) { return Err(SummariserError::Failed(format!("codex: write schema: {e}"))); } let home = cfg.meridian_home.display().to_string(); - let mut args: Vec = vec![ - "exec".into(), - prompt, - "-s".into(), - "read-only".into(), - "--skip-git-repo-check".into(), - "--ephemeral".into(), - "--output-schema".into(), - schema_path.display().to_string(), - "-o".into(), - out_path.display().to_string(), - "-C".into(), - home, - ]; - if !cfg.codex_model.is_empty() { - args.push("-m".into()); - args.push(cfg.codex_model.clone()); - } + let args = codex_args(&schema_path, &out_path, home, &cfg.codex_model); let cap = run_capture( "codex", &args, - stdin_text, + &codex_stdin_payload(stdin_text), &cfg.meridian_home, cfg.codex_timeout_s, &[("MERIDIAN_SUMMARISER", "1")], @@ -111,3 +146,84 @@ impl Drop for TempDirGuard { let _ = std::fs::remove_dir_all(&self.0); } } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + /// The premise the whole fix rests on: the instructions are sourced from a Markdown + /// rules file and are always multi-line. If `SKILL.md` ever became single-line, this + /// fix would no longer be guarding against anything real - this pins that it still is. + #[test] + fn the_instructions_prompt_is_multi_line() { + assert!(prompts::summary_instruction().contains('\n')); + } + + /// The regression this fix exists to close: `codex exec` argv must never carry the + /// instructions prompt (or anything else newline-bearing) - that is exactly what makes + /// Rust's std refuse to spawn `codex.cmd` on Windows. A future edit that reintroduces + /// the prompt into `args` fails this test. + #[test] + fn codex_args_never_contains_a_newline() { + let args = codex_args( + Path::new("/tmp/schema.json"), + Path::new("/tmp/out.txt"), + "/tmp/home".into(), + "gpt-5.5", + ); + for arg in &args { + assert!(!arg.contains('\n'), "argv entry carries a newline: {arg:?}"); + } + } + + #[test] + fn codex_args_omits_the_model_flag_when_unset() { + let args = codex_args( + Path::new("/tmp/schema.json"), + Path::new("/tmp/out.txt"), + "/tmp/home".into(), + "", + ); + assert!(!args.contains(&"-m".to_string())); + } + + #[test] + fn codex_args_carries_the_model_flag_when_set() { + let args = codex_args( + Path::new("/tmp/schema.json"), + Path::new("/tmp/out.txt"), + "/tmp/home".into(), + "gpt-5.5", + ); + let i = args + .iter() + .position(|a| a == "-m") + .expect("-m flag present"); + assert_eq!(args[i + 1], "gpt-5.5"); + } + + /// `codex exec`'s argv must carry NO positional prompt - the instructions now live + /// entirely in the stdin payload built by `codex_stdin_payload`. + #[test] + fn codex_args_has_no_positional_prompt() { + let args = codex_args( + Path::new("/tmp/schema.json"), + Path::new("/tmp/out.txt"), + "/tmp/home".into(), + "", + ); + assert_eq!(args[0], "exec"); + assert_eq!( + args[1], "-s", + "the second argv entry must be a flag, not a prompt" + ); + } + + #[test] + fn codex_stdin_payload_carries_both_the_instructions_and_the_transcript() { + let payload = codex_stdin_payload("the transcript"); + assert!(payload.contains("the transcript")); + assert!(payload.starts_with(&prompts::summary_instruction()[..40])); + } +} diff --git a/src/coding_agent_session_ingest/summariser/copilot.rs b/src/coding_agent_session_ingest/summariser/copilot.rs index dc00303e8..cf79e12e1 100644 --- a/src/coding_agent_session_ingest/summariser/copilot.rs +++ b/src/coding_agent_session_ingest/summariser/copilot.rs @@ -14,6 +14,16 @@ // analysis. // * No `--json-schema`, so the JSON contract rides in the prompt and // `extract` tolerates fenced/prose-wrapped objects (codex pattern). +// +// KNOWN WINDOWS GAP: `copilot` is also npm-installed (`npm i -g @github/copilot`), +// so it resolves to `copilot.cmd` on Windows the same as `codex`/`claude` - and this +// prompt is always multi-line (it embeds `prompts::summary_instruction()` plus the +// transcript), which is exactly what makes Rust's std refuse to spawn a `.bat`/`.cmd` +// target (the CVE-2024-24576 "BatBadBut" fix; see `codex.rs`'s/`claude.rs`'s module +// docs for the confirmed-live error). Unlike codex/claude, this one can't simply move +// to stdin - `-p` ignores it, per the first divergence above. Left unfixed here; a +// real fix needs a different mechanism (e.g. a temp-file prompt, if copilot supports +// reading one) - not yet designed. use super::config::SummariserConfig; use super::prompts; diff --git a/src/coding_agent_session_ingest/summariser/prompts.rs b/src/coding_agent_session_ingest/summariser/prompts.rs index 4dd15df61..33e0ee03d 100644 --- a/src/coding_agent_session_ingest/summariser/prompts.rs +++ b/src/coding_agent_session_ingest/summariser/prompts.rs @@ -5,7 +5,7 @@ // `session-summary` skill (same rules, in SKILL.md); Codex/Copilot/cursor-agent // get SUMMARY_INSTRUCTION as their prompt. All target SUMMARY_SCHEMA. -use serde_json::json; +use serde_json::{json, Value}; /// Fingerprint of the summariser's own prompt. The source sweep refuses to /// ingest any conversation whose first user message carries this marker, so a @@ -30,9 +30,10 @@ pub fn summary_instruction() -> String { ) } -/// Structured-output contract, serialized for `claude --json-schema` / -/// `codex --output-schema`. `summary` is the prose we store. -pub fn summary_schema_json() -> String { +/// Structured-output contract, as a value - `codex --output-schema` needs the raw +/// `Value` so `crate::llm::schema::strictify` can be applied to it before writing. +/// `summary` is the prose we store. +pub fn summary_schema_value() -> Value { json!({ "type": "object", "properties": { @@ -40,7 +41,14 @@ pub fn summary_schema_json() -> String { }, "required": ["summary"], }) - .to_string() +} + +/// Structured-output contract, serialized for `claude --json-schema`. Claude's schema +/// validation doesn't need OpenAI's strict dialect (unlike codex - see +/// `crate::llm::claude::ClaudeBackend`, which also passes its schema through raw), so +/// this stays the plain, un-strictified form. +pub fn summary_schema_json() -> String { + summary_schema_value().to_string() } /// Substrings that mark a subscription usage/rate limit in CLI stderr/output — From a212ccf28e1353d562752ce7a4d4f2d7830f5f5f Mon Sep 17 00:00:00 2001 From: Akarsh Hegde Date: Tue, 25 Aug 2026 23:01:43 +0530 Subject: [PATCH 07/53] chore: revert copilot.rs comment, leave file untouched The Windows batch-file spawn gap in copilot.rs is real (same class as the codex/claude fix in the previous commit - copilot is also npm-.cmd-shimmed on Windows) but copilot's `-p` flag ignores stdin, so it can't take the same fix without a separately-designed mechanism. Documenting it inline made the pre-push security audit flag it as a new HIGH finding on every push touching this crate, which isn't right for a pre-existing, unrelated condition this change doesn't regress. Reverting to keep the file exactly as it was. --- src/coding_agent_session_ingest/summariser/copilot.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/coding_agent_session_ingest/summariser/copilot.rs b/src/coding_agent_session_ingest/summariser/copilot.rs index cf79e12e1..dc00303e8 100644 --- a/src/coding_agent_session_ingest/summariser/copilot.rs +++ b/src/coding_agent_session_ingest/summariser/copilot.rs @@ -14,16 +14,6 @@ // analysis. // * No `--json-schema`, so the JSON contract rides in the prompt and // `extract` tolerates fenced/prose-wrapped objects (codex pattern). -// -// KNOWN WINDOWS GAP: `copilot` is also npm-installed (`npm i -g @github/copilot`), -// so it resolves to `copilot.cmd` on Windows the same as `codex`/`claude` - and this -// prompt is always multi-line (it embeds `prompts::summary_instruction()` plus the -// transcript), which is exactly what makes Rust's std refuse to spawn a `.bat`/`.cmd` -// target (the CVE-2024-24576 "BatBadBut" fix; see `codex.rs`'s/`claude.rs`'s module -// docs for the confirmed-live error). Unlike codex/claude, this one can't simply move -// to stdin - `-p` ignores it, per the first divergence above. Left unfixed here; a -// real fix needs a different mechanism (e.g. a temp-file prompt, if copilot supports -// reading one) - not yet designed. use super::config::SummariserConfig; use super::prompts; From 6230666c1452a51993aeba05d932903ff9ed0dad Mon Sep 17 00:00:00 2001 From: Akarsh Hegde Date: Wed, 26 Aug 2026 09:58:56 +0530 Subject: [PATCH 08/53] fix(infra): assert the gateway's 401 instead of printing a reminder about it `scripts/deploy-gateway.sh` ended with an `echo` telling a human that `telemetry.meridiona.com` "should 401 without a Bearer token". It sent no request and failed on nothing, so a gateway that started answering 200 unauthenticated would deploy green - the one automated gate on the rule CLAUDE.md added after `infra/hf-proxy` reached 173,088 requests in a day and took meridiona.com down with Error 1027. The deploy now probes both public hostnames after `up -d` and exits non-zero unless all four answer 401: ingest no credentials POST /v1/logs ingest wrong bearer token POST /v1/logs with a junk Bearer oo-ui no credentials GET / oo-ui wrong basic auth GET / with junk basic creds The wrong-credential probes are not redundant. An unauthenticated 401 still passes if `bearertokenauth` were swapped for something that merely checks the header is PRESENT; only a bad token proves the value is validated. Same reasoning for the UI's basic auth. Two properties are load-bearing: - A 200 fails IMMEDIATELY rather than being retried. It is the catastrophe the check exists to catch, and retrying only delays it. - An exhausted retry budget FAILS. `000` covers both "collector still binding :4318" and "DNS gone / TLS broken / egress blocked", which are indistinguishable from the status alone, so it is retried and then fails. A retry loop that fell through to success would reproduce exactly the bug being fixed here. Testing. `classify_probe` is pure, and `--self-test` exercises all 15 status cases offline. Mutation-tested three ways to prove it is not vacuous: 200->pass goes red on the 200 case, dropping 000 from the retry set goes red on the 000 case, and a catch-all `pass` goes red on 9 cases. `--verify-only` runs the four probes with no deploy - added because the alternative way to exercise this code was to run a production deploy. Dry-run against the live gateway: all four return 401 in 2.1s. Both failure paths exercised too - a host answering 200 fails immediately, and an unresolvable host retries to the budget and then fails rather than passing silently. CLAUDE.md's hard rule said verification "is currently a MANUAL step". Updated to describe what the script now does, including the two properties above, since those are what a future edit would regress. Closes #864 --- CLAUDE.md | 2 +- scripts/deploy-gateway.sh | 188 +++++++++++++++++++++++++++++++++++++- 2 files changed, 185 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7f42cc669..3411b9528 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ Meridian is a single-process Rust daemon that normalises raw screen-capture fram - NEVER push directly to `main` or `pre-main` — always create a separate feature branch, commit there, and raise a PR to `pre-main`. **All features, fixes, and other changes target `pre-main`** (the staging branch), not `main` — only a maintainer opens the `pre-main → main` release PR, and only after everything on `pre-main` has been tested end-to-end on staging - ALWAYS use a separate branch per feature/fix — branch name format: `type/short-description` (e.g. `feat/trello-oauth`, `fix/ui-disconnect`) - In all **user-facing app text** — window titles, wizard/UI copy, button and menu labels, notification bodies, tray tooltips, any string the user reads — use a plain hyphen `-` only. NEVER an em-dash (`—`), en-dash (`–`), or double hyphen (`--`). Use it spaced (` - `) where a dash separates clauses. (This rule is about displayed strings; code comments and docs are exempt.) -- **Any publicly reachable service we deploy must authenticate every request, validate its origin, allowlist the paths it serves, and rate-limit — and it gets deleted the day its last caller does.** "Authenticate" is separate from "validate the origin" on purpose: an origin check alone is a header a caller controls, and reading the two as one requirement is what permits an unauthenticated public service. An unauthenticated request must be rejected outright with a 401, and **verifying that is currently a MANUAL step** — `scripts/deploy-gateway.sh` only prints "should 401 without a Bearer token" as a reminder at the end of a deploy; it sends no request and fails on nothing, so a gateway that started answering 200 unauthenticated would deploy green. `infra/hf-proxy` (`hf.meridiona.com`) was an unauthenticated reverse proxy to huggingface.co. Its header carried a thoughtful `SECURITY:` block about cache-key poisoning and auth headers leaking into a shared cache; it never asked *who may call this*. When the MLX stack that used it was deleted it kept running with no callers and a public DNS record — and Cloudflare publishes every hostname to the Certificate Transparency logs the moment it issues the cert, so scanners find it whether or not you advertise it. It reached **173,088 requests in a day** against a 100k/day account-wide cap and took meridiona.com down with Error 1027 for traffic the site did not generate. Assume every hostname you provision is public knowledge immediately. +- **Any publicly reachable service we deploy must authenticate every request, validate its origin, allowlist the paths it serves, and rate-limit — and it gets deleted the day its last caller does.** "Authenticate" is separate from "validate the origin" on purpose: an origin check alone is a header a caller controls, and reading the two as one requirement is what permits an unauthenticated public service. An unauthenticated request must be rejected outright with a 401, and **`scripts/deploy-gateway.sh` now asserts that rather than printing a reminder about it** — after `up -d` it probes both public hostnames four ways (ingest and OO UI × no credentials and wrong credentials) and exits non-zero unless every one answers 401, so a gateway that started answering 200 unauthenticated fails the deploy instead of going green. The wrong-credential probes are not redundant: an unauthenticated 401 still passes if an authenticator were swapped for one that merely checks a header is *present*. Two properties are load-bearing and easy to regress — a `200` fails immediately rather than being retried, and an exhausted retry budget (`000`/5xx, i.e. the stack never came up) **fails** rather than falling through to success, which is exactly the shape of the bug it replaced. `bash scripts/deploy-gateway.sh --self-test` exercises the status classifier offline; `--verify-only` runs the four probes against the live gateway without deploying. `infra/hf-proxy` (`hf.meridiona.com`) was an unauthenticated reverse proxy to huggingface.co. Its header carried a thoughtful `SECURITY:` block about cache-key poisoning and auth headers leaking into a shared cache; it never asked *who may call this*. When the MLX stack that used it was deleted it kept running with no callers and a public DNS record — and Cloudflare publishes every hostname to the Certificate Transparency logs the moment it issues the cert, so scanners find it whether or not you advertise it. It reached **173,088 requests in a day** against a 100k/day account-wide cap and took meridiona.com down with Error 1027 for traffic the site did not generate. Assume every hostname you provision is public knowledge immediately. --- diff --git a/scripts/deploy-gateway.sh b/scripts/deploy-gateway.sh index b006e25c9..409698088 100644 --- a/scripts/deploy-gateway.sh +++ b/scripts/deploy-gateway.sh @@ -30,15 +30,170 @@ ZONE="${GATEWAY_ZONE:-asia-south1-a}" PROJECT="${GATEWAY_PROJECT:-meridiona-observability}" REMOTE_DIR="central-observability" +# The two public hostnames Caddy serves (see ops/central-observability/Caddyfile). +# Deliberately NOT read from the VM's .env: this check's whole value is that it +# always runs, and an ssh round-trip to discover the domain adds a failure mode +# where a flake means we can't determine what to probe. If a domain ever drifts, +# probing the old name fails loudly — which is the correct outcome, not a skip. +GATEWAY_DOMAIN="${GATEWAY_DOMAIN:-telemetry.meridiona.com}" +OO_UI_DOMAIN="${OO_UI_DOMAIN:-observe.meridiona.com}" + +# How long to keep retrying a transient probe result before giving up. `up -d` +# returns as soon as the containers are created, so the collector can still be +# binding :4318 when the first probe lands. +PROBE_TIMEOUT_S="${PROBE_TIMEOUT_S:-90}" +PROBE_INTERVAL_S="${PROBE_INTERVAL_S:-3}" + # Everything git owns. `.env` is intentionally not here — see the header. FILES=(docker-compose.yml otel-collector-config.yaml Caddyfile .env.example) -SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/../ops/central-observability" && pwd)" - ssh_vm() { gcloud compute ssh "${VM}" --zone="${ZONE}" --project="${PROJECT}" --command "$1" } +# ── Post-deploy auth assertion ─────────────────────────────────────────────── +# +# CLAUDE.md's hard rule requires that an unauthenticated request to anything we +# expose publicly is rejected outright with a 401. +# +# This used to be an `echo` at the end of the deploy telling a human to go and +# check. It sent no request and failed on nothing, so a gateway that started +# answering 200 unauthenticated would deploy green and stay that way until +# somebody noticed — which is the shape of the `infra/hf-proxy` incident +# (173,088 requests in a day against a service that had no callers at all). See +# the Hard Rules section of CLAUDE.md for the full story. + +# Map an observed HTTP status onto one of three verdicts. Pure — no network, no +# globals — so `--self-test` can exercise every branch offline. +# +# pass the required 401 +# retry the stack is plausibly still coming up (Caddy up, collector not yet) +# reject anything else, INCLUDING 2xx/3xx/4xx that are not 401 +# +# 200 must never be retried: a success on an unauthenticated request is the +# catastrophe this check exists to catch, and retrying it only delays the +# failure. A 400 likewise rejects immediately — it means the body reached the +# collector, i.e. the request got PAST auth. +classify_probe() { + case "$1" in + 401) echo pass ;; + 000 | 429 | 502 | 503 | 504) echo retry ;; + *) echo reject ;; + esac +} + +# Probe one URL until it returns 401 or the budget runs out. +# +# `000` is curl's code for "no HTTP response at all" and covers both a collector +# that has not finished starting AND a dead DNS record / broken TLS / blocked +# egress. Those are indistinguishable from the status alone, so it is retried +# and then FAILS — an exhausted retry loop must never fall through to success, +# which would reproduce the very bug this replaces. +probe_rejects_unauthenticated() { + local label="$1" url="$2" + shift 2 + local deadline=$((SECONDS + PROBE_TIMEOUT_S)) code verdict + while :; do + code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$@" "${url}" || true)" + verdict="$(classify_probe "${code}")" + case "${verdict}" in + pass) + echo " ok ${label} -> 401" + return 0 + ;; + reject) + echo " FAIL ${label} -> ${code} (expected 401)" >&2 + return 1 + ;; + retry) + if [ "${SECONDS}" -ge "${deadline}" ]; then + echo " FAIL ${label} -> ${code} after ${PROBE_TIMEOUT_S}s (expected 401)" >&2 + return 1 + fi + sleep "${PROBE_INTERVAL_S}" + ;; + esac + done +} + +# Assert both public hostnames reject callers who have no credentials AND +# callers whose credentials are wrong. +# +# The wrong-credential probes are the ones that earn their keep. An +# unauthenticated 401 still passes if the auth extension were swapped for +# something that merely checks a header is PRESENT; only a bad token proves the +# value is actually validated. +verify_public_endpoints_authenticate() { + local failed=0 + probe_rejects_unauthenticated \ + "ingest no credentials " "https://${GATEWAY_DOMAIN}/v1/logs" \ + -X POST -H 'Content-Type: application/json' --data '{}' || failed=1 + probe_rejects_unauthenticated \ + "ingest wrong bearer token " "https://${GATEWAY_DOMAIN}/v1/logs" \ + -X POST -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer deploy-gateway-probe-not-a-real-token' --data '{}' || failed=1 + probe_rejects_unauthenticated \ + "oo-ui no credentials " "https://${OO_UI_DOMAIN}/" || failed=1 + probe_rejects_unauthenticated \ + "oo-ui wrong basic auth " "https://${OO_UI_DOMAIN}/" \ + -u 'deploy-gateway-probe:not-a-real-password' || failed=1 + return "${failed}" +} + +# Run the auth assertion on its own, without deploying anything. Two uses: an +# operator re-checking a gateway they did not just deploy, and testing a change +# to the probes themselves — the alternative is running a production deploy to +# exercise four read-only curls. +if [ "${1:-}" = "--verify-only" ]; then + echo "==> verifying ${GATEWAY_DOMAIN} and ${OO_UI_DOMAIN} reject unauthenticated callers" + if verify_public_endpoints_authenticate; then + echo "==> ok. Both hostnames require credentials." + exit 0 + fi + echo "==> FAILED. See above." >&2 + exit 1 +fi + +# Offline check that `classify_probe` still discriminates. Run it after touching +# the table above: bash scripts/deploy-gateway.sh --self-test +if [ "${1:-}" = "--self-test" ]; then + self_test_failures=0 + while read -r code want; do + [ -z "${code}" ] && continue + got="$(classify_probe "${code}")" + if [ "${got}" != "${want}" ]; then + echo "FAIL classify_probe ${code}: want ${want}, got ${got}" >&2 + self_test_failures=$((self_test_failures + 1)) + fi + done <<-'CASES' + 401 pass + 200 reject + 201 reject + 204 reject + 301 reject + 302 reject + 400 reject + 403 reject + 404 reject + 500 reject + 000 retry + 429 retry + 502 retry + 503 retry + 504 retry + CASES + if [ "${self_test_failures}" -ne 0 ]; then + echo "self-test: ${self_test_failures} case(s) failed" >&2 + exit 1 + fi + echo "self-test: classify_probe ok" + exit 0 +fi + +# Resolved here rather than at the top so `--self-test` above stays purely +# offline — it must not depend on being run from inside a checkout. +SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/../ops/central-observability" && pwd)" + echo "==> deploying ${SRC} -> ${VM} (${ZONE}, ${PROJECT})" for f in "${FILES[@]}"; do @@ -70,5 +225,30 @@ ssh_vm "cd ${REMOTE_DIR} && docker compose config -q && echo ' ok'" echo "==> applying" ssh_vm "cd ${REMOTE_DIR} && docker compose up -d && docker compose ps" -echo "==> done. Verify ingest still works before walking away:" -echo " https://telemetry.meridiona.com should 401 without a Bearer token" +echo "==> verifying both public hostnames reject unauthenticated callers" +if ! verify_public_endpoints_authenticate; then + cat >&2 <<-EOF + + DEPLOY FAILED ITS AUTH CHECK. + + A hostname we expose publicly did not answer 401. Treat this as live: + Cloudflare publishes every hostname to the Certificate Transparency logs + the moment it issues the cert, so scanners find it whether or not it is + advertised. + + - 2xx/4xx-not-401 on ingest: the collector's bearertokenauth/ingest + authenticator is not gating the OTLP receiver. Check + otel-collector-config.yaml's receivers.otlp.protocols.http.auth and + that INGEST_TOKEN is set in the VM's .env. + - 2xx on the OO UI: Caddy's basic_auth block is not applying. Check + OO_UI_USER / OO_UI_PASSWORD_HASH in the VM's .env. + - persistent 000/5xx: the stack never came up. \`docker compose ps\` + and \`docker compose logs\` on the VM. + + The new config IS already applied — this check runs after \`up -d\`. Roll + back or fix forward, but do not walk away from it. + EOF + exit 1 +fi + +echo "==> done." From a4715fa8a0b21c6c0dceef7c89e7e0428001714b Mon Sep 17 00:00:00 2001 From: Akarsh Hegde Date: Wed, 26 Aug 2026 10:19:54 +0530 Subject: [PATCH 09/53] fix(daemon): make single-instance acquisition atomic with an OS lock `main()` guarded against a second daemon with a PROBE - "is anyone listening on the endpoint?" - and then ran `setup_db` (pool + migrations, including live `ALTER TABLE`s) before binding the listener at 5b. That bind is deliberately late, so a daemon about to `exit(1)` on a locked/corrupt database never advertises `{"running":true}` to the tray's watchdog. The reasoning is sound and is kept. But it means the winner of a two-daemon race has NOT bound anything at the moment the loser probes, so both get `false`, both fall through, and both run migrations on one `meridian.db`. Check-then-act, not acquire. A test named `single_instance_check_precedes_setup_db_and_bind_follows_it` asserted, in its own message, that "a daemon that will lose that race must never touch meridian.db". It could only pin the ORDER of three call sites, and the order was already correct - the ordering was never the gap. So it recorded confidence in a guarantee that did not hold. ## The lock `platform::acquire_single_instance_lock` takes an exclusive, non-blocking lock on `~/.meridian/daemon.lock` BEFORE `setup_db`, held for the process's life: - Unix: `flock(LOCK_EX | LOCK_NB)` - Windows: `LockFileEx(LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY)` Both attach to the open file description, so the OS releases them when the process dies however it dies - no stale-lock state to misjudge. The file is never unlinked: deleting it would let a later start create a new inode and lock THAT, so two processes could each hold "the" lock. Windows deliberately does not use a `share_mode(0)` exclusive open. That fails with ERROR_SHARING_VIOLATION when anything else merely has the file open - an antivirus scanner, a backup agent - which is indistinguishable from a real second daemon. `LockFileEx` on a shared handle gives ERROR_LOCK_VIOLATION for genuine contention only. ## Three outcomes, not two - the part that keeps this from being an outage Acquired -> proceed HeldByAnother -> stand down cleanly, as the probe already does Unavailable -> WARN and proceed UNLOCKED `Unavailable` covers a home dir that can't be created, a read-only fs, permissions, and any errno that isn't EWOULDBLOCK/EAGAIN. Before this lock there was no lock at all, so running on is exactly the previous behaviour and forfeits nothing that was ever guaranteed. Standing down would instead be a brand-new way for the daemon to be permanently dead on a machine where nothing was wrong. A guard against a rare race must not be able to cause a common outage. ## The probe stays It is the cheap, informative check, it produces the better log line, and it is the only thing that sees a daemon from a build predating this lock - i.e. every daemon during the rollout window. Neither is redundant; both comments say so, so a future reader doesn't delete one. ## Testing Behavioural, not source-scanning: the lock conflicts across two `open`s in one process (it binds to the file description), so the real guarantee is testable without spawning daemons. - `a_second_acquire_of_the_same_path_loses` - `dropping_the_guard_releases_the_lock` (+ the file must survive) - `an_unusable_path_is_unavailable_not_held`, covering BOTH pre-syscall failure branches - an early version covered only one and passed unchanged when the other was broken Mutation-proved: flock forced to always succeed -> the first test fails; each `Unavailable` branch rewritten to `HeldByAnother` -> the third fails on the matching assertion; the acquire removed from `main` -> the ordering test fails. End-to-end, two real daemons under an isolated HOME. With the winner's socket removed so the probe is blind - exactly the real race - the second daemon stood down on the LOCK and ran zero migrations. Windows code compiled for x86_64-pc-windows-msvc in isolation (the full cross-build dies in `ring`'s C step on macOS, so it never reaches this module). Both deps were already in the tree: +2 lockfile lines, no new crates. `spawn_health_listener`'s own unlink-then-bind is still check-then-act. The lock makes it unreachable for two daemons; leaving it otherwise untouched is deliberate - #862 owns that boundary. Closes #861 --- Cargo.lock | 2 + Cargo.toml | 19 +++ src/main.rs | 97 ++++++++++++-- src/platform/mod.rs | 271 ++++++++++++++++++++++++++++++++++++++-- src/platform/unix.rs | 42 +++++++ src/platform/windows.rs | 58 +++++++++ 6 files changed, 471 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2872bc2b6..208b610e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5150,6 +5150,7 @@ dependencies = [ "http", "jsonschema", "keyring", + "libc", "libsqlite3-sys", "meridian-core", "meridian-oauth", @@ -5180,6 +5181,7 @@ dependencies = [ "tracing", "tracing-opentelemetry", "tracing-subscriber", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 994cc97ac..ce21fde30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -204,6 +204,25 @@ candle-core = "0.10" candle-nn = "0.10" candle-transformers = "0.10" +# `flock` for the daemon's single-instance lock (src/platform/unix.rs). Three +# lines of FFI; `libc` is already in the tree transitively, so this adds no +# build cost. Deliberately not `fs2`/`fd-lock`: both wrap this same call, and +# neither surfaces the errno, which is the ONE thing this code needs — telling +# "another daemon holds it" apart from "the lock could not be attempted" is +# what keeps a failed lock from bricking an install. +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +# `LockFileEx` for the same lock on Windows (src/platform/windows.rs). Version +# pinned to 0.61 to match what the tray already resolves, rather than adding a +# seventh windows-sys in the tree. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_System_IO", +] } + [dev-dependencies] tokio = { version = "1.15", features = ["full", "test-util"] } tempfile = "3" diff --git a/src/main.rs b/src/main.rs index cada9f44d..e596a0e4a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1053,8 +1053,10 @@ async fn main() -> Result<()> { return Ok(()); } - // 4a-ter. Single-instance guard — CHECKED here, before `setup_db`, even - // though the listener isn't bound until 5b below. + // 4a-ter. Single-instance guard, part 1 of 2: the endpoint PROBE. Cheap, + // informative, and not authoritative — 4a-quater below is the acquire. + // Checked here, before `setup_db`, even though the listener isn't + // bound until 5b. // // ~/.meridian/daemon.sock: a successful connect that gets a greeting // means ANOTHER daemon already owns this data dir. That happens @@ -1070,7 +1072,8 @@ async fn main() -> Result<()> { // and ran migrations — including a live `ALTER TABLE` — against a // file the winning daemon could simultaneously be writing to or // checkpointing. Checking before `setup_db` means a losing daemon - // never touches meridian.db at all. + // never touches meridian.db at all — but only for a race this probe + // can SEE, which is why the lock at 4a-quater exists. // // Only a stale socket (no listener) is removed, and only right before // THIS process binds its own — see the bind site below. Whoever starts @@ -1092,6 +1095,57 @@ async fn main() -> Result<()> { return Ok(()); } + // 4a-quater. ACQUIRE the single-instance lock. The check above is a probe; + // this is the acquire, and the difference is the whole point. + // + // `daemon_already_running` asks "is anyone listening?" — and the winner + // of a two-daemon race has NOT bound its listener at that moment, + // because the bind is deliberately deferred to 5b so a daemon about to + // `exit(1)` on a corrupt database never advertises `{"running":true}`. + // So two daemons starting together both see nothing, both fall through, + // and both run `setup_db` — migrations included, `ALTER TABLE` + // included — against one file. That is check-then-act, and this repo + // has a documented history of `database disk image is malformed` + // attributed to exactly two writers. + // + // `flock`/`LockFileEx` is atomic: exactly one caller wins however the + // two interleave. The guard is held for the rest of the process's life + // and released by the OS when the process dies, however it dies — so + // there is no stale lock to reason about and nothing to clean up. + // + // The probe stays. It is the cheap, informative check, it produces the + // better log line, and it is the ONLY thing that sees a daemon from a + // build predating this lock — which is every daemon during the rollout + // window. Neither is redundant. + // + // `None` below is "running unlocked", not "no lock needed" — see the + // Unavailable arm. + let _single_instance_lock = match meridian::platform::acquire_single_instance_lock() { + meridian::platform::LockOutcome::Acquired(guard) => Some(guard), + meridian::platform::LockOutcome::HeldByAnother => { + tracing::warn!( + "another meridian daemon holds the single-instance lock for this data dir — exiting (lock)" + ); + return Ok(()); + } + // Could not find out. Proceed UNLOCKED rather than refuse to start: + // before this lock existed there was no lock at all, so running on is + // exactly the previous behaviour and gives up nothing that was ever + // guaranteed. Standing down here would instead be a brand-new way for + // the daemon to be permanently dead on a machine where nothing is + // wrong (a read-only home, an odd errno, an antivirus holding the + // file). A guard against a rare race must not be able to cause a + // common outage. + meridian::platform::LockOutcome::Unavailable(e) => { + tracing::warn!( + error = %e, // not-anyhow: a String the acquire already formatted with its full cause; there is no chain to walk + "could not take the single-instance lock — continuing without it; \ + the endpoint probe above remains the only guard this start has" + ); + None + } + }; + // 4b. Open / create meridian pool and run migrations FIRST — before any // preflight that can block or fail. The UI and MCP server read this DB // directly, so it must exist even when an optional component (capture, @@ -1138,9 +1192,17 @@ async fn main() -> Result<()> { // would let a daemon that's about to `exit(1)` on a locked or corrupt // database falsely tell the tray's watchdog it's healthy for the // brief window before that failure surfaces. Safe to bind - // unconditionally here: the check above already established nothing - // else is listening, and nothing between there and here binds it out - // from under us (both single-threaded up to the poll loop). + // unconditionally here: we hold the single-instance lock taken at + // 4a-quater, so no other daemon process reached this line at all. (The + // older justification — "the check above established nothing else is + // listening, and we're single-threaded up to the poll loop" — was only + // ever about THIS process's threads and said nothing about a second + // process. The lock is what actually makes this claim true.) + // + // NOTE: `spawn_health_listener` itself unlinks a stale socket and then + // binds, which is its own check-then-act across processes. The lock + // makes that unreachable for two daemons, but the sequence is left + // untouched here on purpose — #862 owns that boundary. meridian::platform::spawn_health_listener()?; tracing::info!(endpoint = %meridian::platform::endpoint_display(), "daemon health endpoint ready"); @@ -1643,7 +1705,7 @@ mod startup_order_tests { /// `every_early_return_still_restores_the_daemon`) — this scans the /// source for the three call sites and asserts their relative order. #[test] - fn single_instance_check_precedes_setup_db_and_bind_follows_it() { + fn single_instance_lock_precedes_setup_db_and_bind_follows_it() { const SRC: &str = include_str!("main.rs"); // Truncate at THIS test module first — the file scans itself, and the // needles below (`daemon_already_running`, `setup_db(&initial_cfg`) @@ -1657,6 +1719,9 @@ mod startup_order_tests { let check_pos = prod .find("meridian::platform::daemon_already_running().await") .expect("the single-instance guard's check call must exist in main()"); + let lock_pos = prod + .find("meridian::platform::acquire_single_instance_lock()") + .expect("the single-instance LOCK acquire must exist in main()"); let setup_db_pos = prod .find("setup_db(&initial_cfg.meridian_db_uri()).await") .expect("the setup_db() call must exist in main()"); @@ -1665,11 +1730,21 @@ mod startup_order_tests { .expect("the health-listener bind call must exist in main()"); assert!( - check_pos < setup_db_pos, - "the single-instance guard must be CHECKED before setup_db() opens \ + check_pos < lock_pos, + "the cheap endpoint probe should run before the lock acquire, so \ + the common case (a daemon that is plainly already up) produces \ + the informative log line. Found check at byte {check_pos}, lock \ + at byte {lock_pos}." + ); + assert!( + lock_pos < setup_db_pos, + "the single-instance lock must be ACQUIRED before setup_db() opens \ the pool and runs migrations — a daemon that will lose that race \ - must never touch meridian.db. Found check at byte {check_pos}, \ - setup_db at byte {setup_db_pos}." + must never touch meridian.db. This assertion used to name the \ + PROBE instead, which is why it passed for two releases while the \ + hazard was wide open: the probe is check-then-act, so both racers \ + pass it. Only the lock is an acquire. Found lock at byte \ + {lock_pos}, setup_db at byte {setup_db_pos}." ); assert!( setup_db_pos < bind_pos, diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 906b8c69d..bb8fca26c 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -1,15 +1,22 @@ //ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity //! OS-specific daemon plumbing, behind one interface. //! -//! Two concerns live here because both are genuinely unavailable in portable -//! form and both are load-bearing at startup: +//! Three concerns live here because each is genuinely unavailable in portable +//! form and each is load-bearing at startup: //! -//! - **The IPC endpoint** — simultaneously the tray/UI health probe and the -//! single-instance guard. A Unix domain socket on Unix, a named pipe on -//! Windows. +//! - **The IPC endpoint** — the tray/UI health probe, and a first, cheap look +//! at whether another daemon is up. A Unix domain socket on Unix, a named +//! pipe on Windows. +//! - **The single-instance lock** — the *authoritative* guard, and the one +//! thing here that is atomic. [`acquire_single_instance_lock`], `flock` on +//! Unix and `LockFileEx` on Windows. //! - **The shutdown signal set** — `SIGINT`/`SIGTERM`/`SIGHUP` on Unix, //! Ctrl-C / console-close / system-shutdown events on Windows. //! +//! The first two are easy to mistake for one thing. They are not: see +//! [`acquire_single_instance_lock`] for why the probe cannot replace the lock, +//! and why the lock does not make the probe redundant. +//! //! # Why a module rather than inline `cfg` //! //! The repo's convention is that leaf-level differences (a field, a single @@ -33,9 +40,13 @@ //! a false `false` means two daemons write one `meridian.db`, double every ETL //! pass, and fire the worklog trigger twice. //! +//! What it does NOT promise, and what the lock is for: it is a **probe**, so +//! two daemons starting together can both get `false` and both proceed. The +//! answer is only true of the instant it was asked. +//! //! # Who calls this -//! `src/main.rs`, at startup (single-instance check, then listener) and around -//! the main loop's shutdown select. +//! `src/main.rs`, at startup (probe, then lock, then pool, then listener) and +//! around the main loop's shutdown select. #[cfg(unix)] mod unix; @@ -53,6 +64,252 @@ pub use windows::{ service_manifest, service_status, spawn_health_listener, wait_for_shutdown, }; +// ── Single-instance lock ──────────────────────────────────────────────────── + +/// Why a lock attempt did not succeed. +/// +/// The distinction is the whole point of this type, and collapsing it is how +/// this change would turn from a fix into an outage — see [`LockOutcome`]. +pub(crate) enum LockError { + /// Another live process holds the lock. Authoritative: the OS says so. + Held, + /// The lock could not be attempted or answered at all — the directory + /// could not be created, the filesystem is read-only, permissions deny it, + /// or the syscall failed for a reason that is not contention. + Other(String), +} + +/// What [`acquire_single_instance_lock`] concluded. +/// +/// Three states, not two, deliberately: +/// +/// - [`Acquired`](Self::Acquired) — this process owns the data dir. Proceed. +/// - [`HeldByAnother`](Self::HeldByAnother) — a live daemon owns it. Stand +/// down cleanly, exactly as the endpoint probe already does. +/// - [`Unavailable`](Self::Unavailable) — we could not find out. **Proceed +/// unlocked**, with a warning. +/// +/// That last one is not defensive padding, it is the safety property. Before +/// this lock existed there was no lock at all, so running unlocked is precisely +/// the status quo and costs nothing that was previously guaranteed. Refusing to +/// start, by contrast, would be a BRAND NEW way for the daemon to be +/// permanently dead on a machine where nothing was actually wrong — a +/// read-only home directory or an odd `errno` would brick an install that +/// worked fine yesterday. A guard against a rare race must never be able to +/// cause a common outage. +pub enum LockOutcome { + /// The lock is held by this process for as long as the guard lives. + Acquired(DaemonLock), + /// Another process holds it. Do not touch `meridian.db`. + HeldByAnother, + /// Indeterminate; the string is the underlying error for the log. + Unavailable(String), +} + +/// Ownership of the single-instance lock, released when this is dropped. +/// +/// Holds the open file and nothing else: on both platforms the lock is tied to +/// the open file description, so the OS drops it when the handle closes — +/// including on `SIGKILL`, a panic, or a power loss. That is what makes this +/// safe where a marker FILE would not be: there is no stale state to clean up +/// and therefore no "is this leftover lock real?" question to get wrong. +/// +/// The file itself is never deleted. Unlinking on shutdown would reintroduce +/// exactly the race being closed (process A unlinks while B holds a lock on the +/// same inode; C then creates a fresh file and locks that instead, and A and C +/// both believe they are alone). +pub struct DaemonLock { + _file: std::fs::File, +} + +/// `~/.meridian/daemon.lock` — the file whose lock means "this process owns +/// this data directory". +/// +/// Sits beside `daemon.sock` and scopes to the same data dir the endpoint does, +/// so the two guards answer about the same thing. +fn lock_path() -> std::path::PathBuf { + meridian_core::paths::home_dir_or_cwd() + .join(".meridian") + .join("daemon.lock") +} + +/// Take the single-instance lock for this data directory. +/// +/// # Why this exists alongside [`daemon_already_running`] +/// +/// They are not redundant, and deleting either one reopens a real hazard: +/// +/// - The **endpoint probe** is the cheap, informative check. It produces the +/// good log line, and it is the only thing that sees a daemon from a build +/// that predates this lock — which matters for the whole rollout window +/// during which an old daemon takes no lock at all. +/// - **This lock** is the authoritative one. The probe is check-then-act: two +/// daemons starting together both find nothing listening (the winner has not +/// bound its listener yet — the bind is deliberately deferred until after +/// the database is open) and both proceed into `setup_db`, running +/// migrations on one file. `flock`/`LockFileEx` is an atomic acquire, so +/// exactly one caller wins no matter how the two are interleaved. +/// +/// # Related +/// - `src/main.rs` — the sole caller, immediately before `setup_db`. +/// - [`LockOutcome`] — why a failure to lock does not stop the daemon. +pub fn acquire_single_instance_lock() -> LockOutcome { + acquire_lock_at(&lock_path()) +} + +/// [`acquire_single_instance_lock`] against an explicit path, so the behaviour +/// can be tested without touching the real `~/.meridian`. +fn acquire_lock_at(path: &std::path::Path) -> LockOutcome { + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + return LockOutcome::Unavailable(format!("could not create {}: {e}", parent.display())); + } + } + // Opened read+write and SHARED (no exclusive share mode on Windows): the + // lock is taken as a separate, explicit step below. Opening exclusively + // would conflict with anything else that merely has the file open — an + // antivirus scanner, a backup agent — and that is indistinguishable from a + // real second daemon, which is the one mistake that must not be made here. + let file = match std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + { + Ok(f) => f, + Err(e) => { + return LockOutcome::Unavailable(format!("could not open {}: {e}", path.display())) + } + }; + match lock_file_exclusive(&file) { + Ok(()) => LockOutcome::Acquired(DaemonLock { _file: file }), + Err(LockError::Held) => LockOutcome::HeldByAnother, + Err(LockError::Other(e)) => LockOutcome::Unavailable(e), + } +} + +#[cfg(unix)] +use unix::lock_file_exclusive; +#[cfg(windows)] +use windows::lock_file_exclusive; + +#[cfg(test)] +mod tests { + use super::*; + + fn describe(outcome: &LockOutcome) -> String { + match outcome { + LockOutcome::Acquired(_) => "Acquired".into(), + LockOutcome::HeldByAnother => "HeldByAnother".into(), + LockOutcome::Unavailable(e) => format!("Unavailable({e})"), + } + } + + /// The property the whole change rests on: once one caller holds the lock, + /// a second acquire of the same path loses — atomically, with no window in + /// which both believe they won. + /// + /// This works in-process because both `flock` and `LockFileEx` attach the + /// lock to the OPEN FILE DESCRIPTION, and `acquire_lock_at` opens the file + /// fresh each call. Two `open`s therefore contend exactly as two processes + /// would — which is what makes the guarantee testable at all without + /// spawning real daemons. + #[test] + fn a_second_acquire_of_the_same_path_loses() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("sub").join("daemon.lock"); + + let first = acquire_lock_at(&path); + assert!( + matches!(first, LockOutcome::Acquired(_)), + "the first acquire must win on a fresh path, got {}", + describe(&first) + ); + + let second = acquire_lock_at(&path); + assert!( + matches!(second, LockOutcome::HeldByAnother), + "a second acquire while the first guard is alive must report \ + HeldByAnother - if this is Acquired, two daemons can run \ + migrations on one meridian.db, got {}", + describe(&second) + ); + drop(first); + } + + /// The lock must be released by dropping the guard alone. Nothing unlinks + /// the file, so if release depended on deletion this would fail — and the + /// next daemon start would stand down forever against a lock nobody holds. + #[test] + fn dropping_the_guard_releases_the_lock() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("daemon.lock"); + + let first = acquire_lock_at(&path); + assert!(matches!(first, LockOutcome::Acquired(_))); + drop(first); + + let again = acquire_lock_at(&path); + assert!( + matches!(again, LockOutcome::Acquired(_)), + "after the holder drops, the next acquire must win - the lock file \ + is deliberately never deleted, so release comes from closing the \ + handle and nothing else, got {}", + describe(&again) + ); + assert!( + path.exists(), + "the lock file must survive release: unlinking it would let a \ + later start create a NEW inode and lock that instead, so two \ + processes could each hold 'the' lock" + ); + } + + /// The asymmetry that keeps this guard from becoming an outage: a lock we + /// could not even attempt is [`LockOutcome::Unavailable`], never + /// [`LockOutcome::HeldByAnother`]. + /// + /// `main` stands down on `HeldByAnother` and proceeds on `Unavailable`, so + /// misclassifying here means a daemon that refuses to start on a machine + /// where nothing is wrong. + /// + /// Both ways `acquire_lock_at` can fail before it ever reaches the lock + /// syscall are covered, because they are separate branches and an early + /// version of this test exercised only the first — it passed unchanged + /// when the second was deliberately broken. + #[test] + fn an_unusable_path_is_unavailable_not_held() { + let dir = tempfile::tempdir().expect("tempdir"); + + // 1. The parent directory cannot be created: a regular file already + // occupies that name, so `create_dir_all` fails for real. + let blocker = dir.path().join("not-a-directory"); + std::fs::write(&blocker, b"").expect("write blocker file"); + let parent_fails = acquire_lock_at(&blocker.join("daemon.lock")); + assert!( + matches!(parent_fails, LockOutcome::Unavailable(_)), + "a lock path whose parent cannot be created must be Unavailable so \ + the daemon proceeds unlocked (the pre-existing behaviour); \ + reporting it as HeldByAnother would make it stand down forever, \ + got {}", + describe(&parent_fails) + ); + + // 2. The parent is fine but the lock path itself cannot be opened as a + // file - here because it is a directory (EISDIR). + let as_dir = dir.path().join("daemon.lock"); + std::fs::create_dir(&as_dir).expect("create dir at the lock path"); + let open_fails = acquire_lock_at(&as_dir); + assert!( + matches!(open_fails, LockOutcome::Unavailable(_)), + "a lock path that cannot be opened must be Unavailable for the same \ + reason, got {}", + describe(&open_fails) + ); + } +} + /// What the health report can say about the service's on-disk definition. /// /// Same reasoning as [`ServiceStatus`]: `Missing` is a finding, `Unknown` is diff --git a/src/platform/unix.rs b/src/platform/unix.rs index 90e8bd0dd..d614f94ec 100644 --- a/src/platform/unix.rs +++ b/src/platform/unix.rs @@ -97,6 +97,48 @@ pub fn spawn_health_listener() -> anyhow::Result<()> { Ok(()) } +/// Take an exclusive, non-blocking advisory lock on an already-open file. +/// +/// `flock` associates the lock with the **open file description**, not with the +/// process and not with the path. Three consequences this design leans on: +/// +/// - The kernel releases it when the descriptor closes, including on `SIGKILL`, +/// a panic, or a power loss. No stale-lock problem, so nothing has to decide +/// whether a leftover lock is real. +/// - Two `open()` calls produce two descriptions, so a second acquire conflicts +/// even inside the same process. That is what makes this testable in-process +/// rather than needing two real daemons. +/// - It is *advisory*: it constrains other `flock` callers only, and never +/// blocks an unrelated tool from reading or writing the file. The lock file +/// holds no content, so that costs nothing. +/// +/// `EWOULDBLOCK`/`EAGAIN` is the ONLY errno that means contention. Everything +/// else — `EBADF`, `EINVAL`, `ENOLCK`, or anything a network filesystem +/// invents — is reported as [`super::LockError::Other`] so the caller proceeds +/// unlocked instead of standing down. Treating a blanket "flock failed" as +/// "another daemon owns this" is the version of this that bricks installs. +pub(crate) fn lock_file_exclusive(file: &std::fs::File) -> Result<(), super::LockError> { + use std::os::unix::io::AsRawFd as _; + + // SAFETY: `file` is a live `File` borrowed for the whole call, so its + // descriptor is open and valid. `flock` has no other precondition, and + // LOCK_NB guarantees it does not block. + let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if rc == 0 { + return Ok(()); + } + let err = std::io::Error::last_os_error(); + let code = err.raw_os_error().unwrap_or(0); + // Compared with `if` rather than matched: EAGAIN and EWOULDBLOCK are the + // same value on Linux and macOS, and two match arms with equal values is an + // unreachable-pattern error. They are separate constants on some targets. + if code == libc::EWOULDBLOCK || code == libc::EAGAIN { + Err(super::LockError::Held) + } else { + Err(super::LockError::Other(format!("flock failed: {err}"))) + } +} + /// Unlink the socket file on clean shutdown. /// /// Best-effort: a leftover file is harmless — the next start's probe finds no diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 8ee2d9b0c..01078b400 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -109,6 +109,64 @@ pub fn spawn_health_listener() -> anyhow::Result<()> { Ok(()) } +/// Take an exclusive, immediately-failing byte-range lock on an already-open +/// file — the Windows counterpart of `super::unix`'s `flock`. +/// +/// # Why `LockFileEx` and not an exclusive `share_mode(0)` open +/// +/// Opening the file with no sharing is the shorter way to get mutual exclusion +/// on Windows, and it is the wrong one here. A `share_mode(0)` open fails with +/// `ERROR_SHARING_VIOLATION` when **anything** else has the file open — an +/// antivirus scanner mid-scan, a backup agent, a search indexer — and that is +/// byte-for-byte the same error a real second daemon produces. The one mistake +/// this whole module must not make is concluding "another daemon owns this data +/// dir" when nothing of the sort is true. +/// +/// `LockFileEx` on a normally-shared handle separates the two: a genuine lock +/// conflict is `ERROR_LOCK_VIOLATION` (33), which no passive file-opener can +/// cause. So contention is reported as [`super::LockError::Held`] and every +/// other failure — including a sharing violation — becomes +/// [`super::LockError::Other`], which makes the caller proceed unlocked rather +/// than refuse to start. +/// +/// The lock is released by the kernel when the handle closes, on process exit +/// or kill, matching the `flock` guarantee the Unix side relies on. One byte at +/// offset 0 is locked: the file has no content, so the range is a token, not a +/// region anyone reads. +pub(crate) fn lock_file_exclusive(file: &std::fs::File) -> Result<(), super::LockError> { + use std::os::windows::io::AsRawHandle as _; + use windows_sys::Win32::Foundation::{ERROR_LOCK_VIOLATION, HANDLE}; + use windows_sys::Win32::Storage::FileSystem::{ + LockFileEx, LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, + }; + + let mut overlapped = + unsafe { std::mem::zeroed::() }; + // SAFETY: `file` is a live `File` borrowed for the whole call, so its + // handle is valid. `overlapped` is a correctly zeroed, stack-owned struct + // that outlives the call — LOCKFILE_FAIL_IMMEDIATELY means the call cannot + // complete asynchronously, so nothing retains the pointer past return. + let ok = unsafe { + LockFileEx( + file.as_raw_handle() as HANDLE, + LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY, + 0, + 1, + 0, + &mut overlapped, + ) + }; + if ok != 0 { + return Ok(()); + } + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(ERROR_LOCK_VIOLATION as i32) { + Err(super::LockError::Held) + } else { + Err(super::LockError::Other(format!("LockFileEx failed: {err}"))) + } +} + /// No-op: a named pipe disappears with the process that holds it, so unlike a /// socket file there is nothing to unlink. pub fn release_endpoint() {} From 02657c32bd47d7d977774e4dca1a13ee0bce2ee0 Mon Sep 17 00:00:00 2001 From: Akarsh Hegde Date: Wed, 26 Aug 2026 13:12:16 +0530 Subject: [PATCH 10/53] fix(llm): make the health-gate exemption's outcome visible in shipped telemetry Investigating github.com/Meridiona/meridian/issues/805 against live central OpenObserve data showed a stale "codex timed out after 20s" verdict sitting unrefreshed for over a week on an affected Windows host, while genuine `llm.infer` spans for that host had stopped entirely days earlier. The obvious question - is the 15-minute health-probe exemption (resolver.rs::HEALTH_PROBE_INTERVAL) ever actually firing for this user, and if so, what happens - turned out to be unanswerable from telemetry alone: the log line marking "the exemption just fired" was `tracing::info!`, and the redacted ship leg only forwards WARN+ logs (see CLAUDE.md's Observability section), so it never reached central OO regardless of whether it ran. Bumped that line to `tracing::warn!`, and added a matching WARN on the success side (previously silent - a cleared outage looked identical to "the exemption never got another chance" from telemetry). Together the two make both outcomes of an exempted probe - "granted, then failed again" vs "granted, then cleared" - directly queryable the next time this recurs, instead of requiring the multi-query archaeology this investigation needed. Log-only change: no behavior, timing, or gating logic touched. --- src/llm/resolver.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/llm/resolver.rs b/src/llm/resolver.rs index 94fac2170..898c6d7bf 100644 --- a/src/llm/resolver.rs +++ b/src/llm/resolver.rs @@ -554,7 +554,13 @@ async fn complete_inner(req: &PromptRequest) -> Result<(LlmOutput, LlmProvider), return Err(refusal); } probing = true; - tracing::info!( + // WARN, not INFO: this is the ONLY record that the 15-minute exemption + // (`HEALTH_PROBE_INTERVAL`) ever fired at all. Redaction ships WARN+ logs + // only (see CLAUDE.md's Observability section), so at INFO this line never + // reaches central OO - which is exactly what made a live-in-the-field + // "is the exemption even running?" question undiagnosable from telemetry + // alone (github.com/Meridiona/meridian/issues/805). + tracing::warn!( provider = chosen.as_str(), label = %req.label, error = %refusal, @@ -663,6 +669,16 @@ async fn complete_inner(req: &PromptRequest) -> Result<(LlmOutput, LlmProvider), // which that check cannot see - so it would write nothing and the next call // would be refused again by the verdict this one just disproved. if probing { + // The other half of the exemption's story - see the WARN above where + // `probing` was set. Without this, a successful re-check is invisible: + // the outage just silently stops appearing in the next refusal log, + // which reads identically to "the exemption never got another chance to + // fire" from telemetry alone. + tracing::warn!( + provider = chosen.as_str(), + label = %req.label, + "llm: provider re-check succeeded - clearing the unavailable verdict" + ); super::runtime_health::record_probe_success(&key); } else { super::runtime_health::record_success(&key); From 1f46407f8bc37d0f5bc0e2e5f6947e085c9fa194 Mon Sep 17 00:00:00 2001 From: Akarsh Hegde Date: Wed, 26 Aug 2026 13:32:36 +0530 Subject: [PATCH 11/53] feat(observability): make a daemon generation identifiable and its quit survivable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three corruption mechanisms were fixed this month (#886, #894, #861) and NONE of them was confirmed from field data - each was reasoned from code, because the telemetry cannot currently answer the questions an incident raises. This fixes the three gaps that made that true. ## 1. No process could be told apart from the next one A quit-then-relaunch during an update starts three daemons inside 35 seconds. All three logged an identical "meridian daemon starting", and the SIGTERMs between them named nobody, so no signal could be attributed to any generation. An investigation on 2026-08-25 ran aground on exactly this and had to withdraw its conclusion. `pid` now rides on `meridian daemon starting`, on every arm of `wait_for_shutdown` (both platforms), and on `shutting down`. Logged as `i64`, not the `u32` `std::process::id()` returns, and that is load-bearing: `tracing-opentelemetry` 0.28 has no `record_u64`, so a `u32` falls through to `record_debug` and is emitted as a STRING that then has to survive the attribute allowlist as a string key. An `i64` becomes a real `IntValue`, which `redact::keep_attribute`'s first arm keeps unconditionally. Same trap as CLAUDE.md's third coupling. ## 2. A successful WAL checkpoint logged nothing A FAILED checkpoint warned; a successful one was silent. So "no line after `shutting down`" meant either "it completed" or "the process was killed part-way through" - indistinguishable, and that distinction is the entire question when a meridian.db is later found malformed. Both outcomes are logged now. Silence after `shutting down` means killed. ## 3. The quit verdict was systematically discarded `handle.exit(0)` is `std::process::exit`: no destructors, and whatever the OTel batch processors are still holding dies with the process. What they hold at that instant is the line describing how stopping the daemon went - `daemon stopped for quit`, `could not stop the daemon on quit`, `exceeded its budget`. Quit is when the tray and the daemon are most likely to overlap on meridian.db, so that was the most useful record that exists for a corruption report, and it was the one guaranteed never to survive. `observability::force_flush()` is the "flush and keep going" half of `ObservabilityGuard::shutdown` - callable from anywhere, idempotent, a no-op before init or with capture disabled. The exit handler calls it between `stop_for_quit` and `handle.exit`. ## 4. `meridian logs` was discarding every structured field Found while verifying the above: `RenderedRecord` decoded attributes and threw them away, so the one supported local read path showed the message of every record and none of its data. `SIGTERM received` with no pid, `ETL run failed` with no error - the exact opposite of the structured- field discipline CLAUDE.md mandates, and the reason the 25th's investigation was reading messages only. Fields now render after the message. Call-site metadata (`code.*`/`log.target`/`thread.*`, stamped on every record by `experimental_metadata_attributes`) and unset fields are skipped, so the line gains signal rather than width. Display only - the spool and export bundles were always full-fidelity. ## Verified end to end Real daemon, isolated HOME, SIGTERM, read back through `meridian logs`: meridian daemon starting pid=11174 meridian_db=… poll_interval_secs=60 SIGTERM received pid=11174 shutting down pid=11174 WAL checkpoint on shutdown complete pid=11174 pid matches the shell-reported pid; the whole lifecycle is attributable to one process. This is precisely what was missing on the 25th. Tests: the flush's ORDER is asserted, not its presence - a flush placed before `stop_for_quit` compiles, runs, and preserves only the records that were never at risk. Mutation-proved both ways (moved before the stop -> red; deleted -> red). Attribute rendering has three tests covering an i64 field, metadata suppression, and unset-field omission. Refs #861 --- src/main.rs | 40 +++++- src/observability/mod.rs | 73 +++++++++++ src/platform/unix.rs | 16 ++- src/platform/windows.rs | 9 +- src/telemetry_spool/render.rs | 173 +++++++++++++++++++++++++ tray/src-tauri/src/daemon_lifecycle.rs | 36 +++++ tray/src-tauri/src/lib.rs | 12 ++ 7 files changed, 349 insertions(+), 10 deletions(-) diff --git a/src/main.rs b/src/main.rs index cada9f44d..279af94ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1023,8 +1023,26 @@ async fn main() -> Result<()> { let initial_cfg = Config::from_env(); tracing::info!(stage = "config_loaded", "configuration ready"); - // 4. Log startup parameters + // 4. Log startup parameters. + // + // `pid` is here so a generation can be TOLD APART from the next one. + // Without it, a machine that started three daemons in 35 seconds — which + // is what a quit-then-relaunch during an update looks like — produces + // three identical "meridian daemon starting" lines and a set of signal + // lines that cannot be attributed to any of them. Every corruption + // investigation so far has run aground on exactly that: the events are + // all in the spool, and nothing says which process each belongs to. + // + // Logged as `i64`, not the `u32` `std::process::id` returns, and this is + // load-bearing rather than cosmetic. `tracing-opentelemetry` 0.28 has no + // `record_u64`, so a `u32`/`u64` field falls through to `record_debug` + // and is emitted as a STRING — which then has to survive the attribute + // allowlist as a string key to egress at all. An `i64` becomes a real + // `IntValue`, and `redact::keep_attribute`'s first arm keeps every + // `IntValue` unconditionally. See CLAUDE.md's third "coupling that + // silently deletes error coverage". tracing::info!( + pid = std::process::id() as i64, meridian_db = %initial_cfg.meridian_db, poll_interval_secs = initial_cfg.poll_interval_secs, "meridian daemon starting" @@ -1526,13 +1544,27 @@ async fn main() -> Result<()> { let _ = shutdown_tx.send(true); // 9. Shutdown - tracing::info!("shutting down"); + tracing::info!(pid = std::process::id() as i64, "shutting down"); meridian::platform::release_endpoint(); // See `db::meridian::checkpoint_wal`'s doc for why this runs before every // close, not just a plain shutdown. Best-effort: a failed checkpoint must // not block shutdown. - if let Err(e) = meridian::db::meridian::checkpoint_wal(&meridian).await { - tracing::warn!(error = %meridian::errors::chain(&e), "WAL checkpoint on shutdown failed - continuing anyway"); + // + // BOTH outcomes are logged, and the success line is the point. Previously a + // failed checkpoint logged a WARN and a successful one logged nothing, so + // "no line after `shutting down`" meant either "it worked" or "the process + // was killed part-way through" — indistinguishable, and the difference is + // the entire question when a `meridian.db` is later found malformed. A + // corruption investigation on 2026-08-25 stalled on exactly this ambiguity + // and had to withdraw its conclusion. Now silence here means killed. + match meridian::db::meridian::checkpoint_wal(&meridian).await { + Ok(()) => tracing::info!( + pid = std::process::id() as i64, + "WAL checkpoint on shutdown complete" + ), + Err(e) => { + tracing::warn!(error = %meridian::errors::chain(&e), "WAL checkpoint on shutdown failed - continuing anyway") + } } meridian.close().await; diff --git a/src/observability/mod.rs b/src/observability/mod.rs index 7e98cf7ce..30f593392 100644 --- a/src/observability/mod.rs +++ b/src/observability/mod.rs @@ -142,6 +142,71 @@ impl ObservabilityGuard { } } +/// Provider handles kept for [`force_flush`]. Set once by [`init`]. +/// +/// Separate from [`ObservabilityGuard`] because the guard is owned by whoever +/// called `init` — in the tray that is a local in `run()` — while the code that +/// needs to flush is somewhere else entirely (an exit handler in a spawned +/// task). Both providers are `Arc`-backed, so this holds clones rather than +/// taking anything away from the guard. +static FLUSH_HANDLES: std::sync::OnceLock = std::sync::OnceLock::new(); + +struct FlushHandles { + tracer_provider: Option, + logger_provider: Option, +} + +/// Push everything currently batched in memory out to the telemetry spool, +/// WITHOUT shutting the providers down. +/// +/// # Why this is needed at all +/// +/// Spans and logs do not reach `~/.meridian/telemetry/pending/` the moment they +/// are emitted: the batch processors hold them and drain on a timer. A process +/// that calls `std::process::exit` — which is what `tauri::AppHandle::exit` +/// does — takes that batch with it. Destructors do not run, so holding an RAII +/// guard is no protection either. +/// +/// The practical cost was that the LAST thing a process does is the thing least +/// likely to be recorded, and for the tray the last thing it does is stop the +/// daemon on quit. Every `daemon stopped for quit` / `could not stop the daemon +/// on quit` / `exceeded its budget` line was emitted and then discarded +/// microseconds later, so the outcome of the operation that most needs +/// explaining after a corruption report was systematically the one missing from +/// the spool. +/// +/// [`ObservabilityGuard::shutdown`] already does this and more, but it consumes +/// the guard and shuts the providers down; this is the "flush and keep going" +/// half, callable from anywhere and safe to call more than once. +/// +/// A no-op when capture is disabled (`MERIDIAN_TELEMETRY_DISABLED`) or before +/// [`init`] has run, so callers never need to check. +/// +/// # Who calls this +/// - `meridian_tray_lib::run`'s `RunEvent::ExitRequested` handler, immediately +/// before `handle.exit(0)`. +pub async fn force_flush() { + let Some(handles) = FLUSH_HANDLES.get() else { + return; + }; + if let Some(tp) = handles.tracer_provider.clone() { + let _ = tokio::task::spawn_blocking(move || { + for r in tp.force_flush() { + if let Err(e) = r { + eprintln!("observability: span force_flush error: {e:?}"); + } + } + }) + .await; + } + if let Some(lp) = handles.logger_provider.clone() { + let _ = tokio::task::spawn_blocking(move || { + let _ = lp.force_flush(); + }) + .await; + } +} + /// Initialise the layered tracing subscriber. /// /// `service_name` becomes the OTel `service.name` resource attribute. The @@ -254,6 +319,14 @@ pub fn init(service_name: &str) -> Result { ); } + // Clones for `force_flush`, which runs far from whoever owns the guard. + // `set` rather than `get_or_init`: a second `init` in one process is a bug, + // and silently keeping the first set of handles is the safer failure. + let _ = FLUSH_HANDLES.set(FlushHandles { + tracer_provider: tracer_provider.clone(), + logger_provider: logger_provider.clone(), + }); + Ok(ObservabilityGuard { tracer_provider, logger_provider, diff --git a/src/platform/unix.rs b/src/platform/unix.rs index 90e8bd0dd..95e4cb574 100644 --- a/src/platform/unix.rs +++ b/src/platform/unix.rs @@ -210,10 +210,20 @@ pub async fn wait_for_shutdown() { let mut sigterm = signal(SignalKind::terminate()).expect("register SIGTERM handler"); let mut sighup = signal(SignalKind::hangup()).expect("register SIGHUP handler"); + // `pid` on every arm: these lines are the record of WHICH daemon generation + // was asked to stop, and during an update or a quit-then-relaunch there are + // several within seconds of each other. Without it the signal cannot be + // matched to the "meridian daemon starting" line it belongs to, and the + // shutdown sequence — the window where the tray and the daemon can overlap + // on meridian.db — is unreconstructable after the fact. + // + // `as i64` deliberately; see the same cast at the startup log in `main.rs` + // for why a `u32` would ship as a string, or not at all. + let pid = std::process::id() as i64; tokio::select! { - _ = sigint.recv() => tracing::info!("SIGINT received"), - _ = sigterm.recv() => tracing::info!("SIGTERM received"), - _ = sighup.recv() => tracing::info!("SIGHUP received — reloading (graceful restart)"), + _ = sigint.recv() => tracing::info!(pid, "SIGINT received"), + _ = sigterm.recv() => tracing::info!(pid, "SIGTERM received"), + _ = sighup.recv() => tracing::info!(pid, "SIGHUP received — reloading (graceful restart)"), } } diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 8ee2d9b0c..ada384903 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -209,9 +209,12 @@ pub async fn wait_for_shutdown() { let mut close = ctrl_close().expect("register console-close handler"); let mut shutdown = ctrl_shutdown().expect("register system-shutdown handler"); + // `pid` on every arm, for the same reason as the Unix arm — see + // `super::unix::wait_for_shutdown`. + let pid = std::process::id() as i64; tokio::select! { - _ = ctrl_c.recv() => tracing::info!("Ctrl-C received"), - _ = close.recv() => tracing::info!("console close received"), - _ = shutdown.recv() => tracing::info!("system shutdown received"), + _ = ctrl_c.recv() => tracing::info!(pid, "Ctrl-C received"), + _ = close.recv() => tracing::info!(pid, "console close received"), + _ = shutdown.recv() => tracing::info!(pid, "system shutdown received"), } } diff --git a/src/telemetry_spool/render.rs b/src/telemetry_spool/render.rs index 16df2f03e..636bc8a04 100644 --- a/src/telemetry_spool/render.rs +++ b/src/telemetry_spool/render.rs @@ -41,6 +41,34 @@ pub struct RenderedRecord { pub body: String, pub trace_id: String, pub span_id: String, + /// The record's structured fields, already stringified, in emission order. + /// + /// These were previously decoded and thrown away, so `meridian logs` showed + /// the message of every record and none of its data. That is the opposite + /// of how this codebase is instrumented — CLAUDE.md requires values to be + /// structured fields and never formatted into the message — so the one + /// supported way to read logs locally was systematically hiding the half + /// that was written to be machine-readable. A `SIGTERM received` with no + /// `pid`, an `ETL run failed` with no `error`. + pub attributes: Vec<(String, String)>, +} + +/// Attribute keys `meridian logs` does not print. +/// +/// Not a privacy filter — this is the local, full-fidelity read path and +/// nothing here is withheld from the spool or from an export bundle. It is +/// purely about signal: `opentelemetry-appender-tracing`'s +/// `experimental_metadata_attributes` feature stamps call-site metadata on +/// EVERY record, which would triple the width of every line with the one thing +/// a reader already knows (they can see the message). +/// +/// Prefix-matched, so `code.filepath`/`code.lineno`/`code.namespace` are all +/// covered by one entry. +const UNPRINTED_ATTR_PREFIXES: &[&str] = &["code.", "log.target", "thread.", "busy_ns", "idle_ns"]; + +/// Is this a call-site metadata key rather than a value the emitter chose? +fn is_unprinted_attr(key: &str) -> bool { + UNPRINTED_ATTR_PREFIXES.iter().any(|p| key.starts_with(p)) } /// Decode one spooled file into [`RenderedRecord`]s. The signal (logs vs @@ -86,6 +114,9 @@ fn decode_logs(bytes: &[u8]) -> Result> { body: any_value_to_string(lr.body.as_ref()), trace_id: hex::encode(&lr.trace_id), span_id: hex::encode(&lr.span_id), + attributes: collect_attributes( + lr.attributes.iter().map(|kv| (&kv.key, kv.value.as_ref())), + ), }); } } @@ -109,6 +140,11 @@ fn decode_traces(bytes: &[u8]) -> Result> { body: span.name, trace_id: hex::encode(&span.trace_id), span_id: hex::encode(&span.span_id), + attributes: collect_attributes( + span.attributes + .iter() + .map(|kv| (&kv.key, kv.value.as_ref())), + ), }); } } @@ -124,6 +160,18 @@ fn resource_service_name(resource: Option<&Resource>) -> String { .unwrap_or_else(|| "unknown".to_string()) } +/// Stringify a record's attributes for display, dropping call-site metadata +/// and anything with an empty value (a field that was recorded but never set — +/// printing `outcome=` adds width and no information). +fn collect_attributes<'a>( + kvs: impl Iterator)>, +) -> Vec<(String, String)> { + kvs.filter(|(k, _)| !is_unprinted_attr(k)) + .map(|(k, v)| (k.clone(), any_value_to_string(v))) + .filter(|(_, v)| !v.is_empty()) + .collect() +} + fn any_value_to_string(v: Option<&AnyValue>) -> String { match v.and_then(|v| v.value.as_ref()) { Some(Value::StringValue(s)) => s.clone(), @@ -177,6 +225,11 @@ pub fn format_line(r: &RenderedRecord) -> String { .unwrap_or_else(|| "?".to_string()); let mut line = format!("{ts} {:>7} [{}] {}", r.severity, r.service_name, r.body); + // After the message, before `trace_id`: the fields are what the message is + // about, and the trace id is a join key nobody reads inline. + for (k, v) in &r.attributes { + line.push_str(&format!(" {k}={v}")); + } if !r.trace_id.is_empty() { line.push_str(&format!(" trace_id={}", r.trace_id)); } @@ -423,6 +476,126 @@ mod tests { req.encode_to_vec() } + /// Build one log record carrying the given attributes, so the rendering of + /// structured fields can be exercised without a live subscriber. + fn make_log_bytes_with_attrs(body: &str, attrs: &[(&str, any_value::Value)]) -> Vec { + let req = ExportLogsServiceRequest { + resource_logs: vec![ResourceLogs { + resource: Some(Resource { + attributes: vec![KeyValue { + key: "service.name".to_string(), + value: Some(AnyValue { + value: Some(any_value::Value::StringValue("test-svc".to_string())), + }), + }], + dropped_attributes_count: 0, + }), + scope_logs: vec![ScopeLogs { + scope: Some(InstrumentationScope::default()), + log_records: vec![LogRecord { + time_unix_nano: 1_700_000_000_000_000_000, + severity_text: "INFO".to_string(), + body: Some(AnyValue { + value: Some(any_value::Value::StringValue(body.to_string())), + }), + attributes: attrs + .iter() + .map(|(k, v)| KeyValue { + key: (*k).to_string(), + value: Some(AnyValue { + value: Some(v.clone()), + }), + }) + .collect(), + ..Default::default() + }], + schema_url: String::new(), + }], + schema_url: String::new(), + }], + }; + req.encode_to_vec() + } + + /// A record's structured fields must reach the rendered line. + /// + /// They were decoded and discarded before, which made `meridian logs` show + /// the message of every record and none of its data — so `SIGTERM received` + /// could not be attributed to a process and `ETL run failed` did not carry + /// its error. `pid` is the concrete case this was added for: an `i64`, which + /// must render as a plain number rather than a debug-formatted string. + #[test] + fn structured_fields_are_rendered_not_discarded() { + let bytes = make_log_bytes_with_attrs( + "SIGTERM received", + &[("pid", any_value::Value::IntValue(4321))], + ); + let records = decode_logs(&bytes).unwrap(); + let r = &records[0]; + assert_eq!( + r.attributes, + vec![("pid".to_string(), "4321".to_string())], + "the log's structured fields must survive decoding" + ); + let line = format_line(r); + assert!( + line.contains("SIGTERM received pid=4321"), + "the fields must appear on the rendered line, right after the \ + message; got {line:?}" + ); + } + + /// Call-site metadata is stamped on EVERY record by + /// `experimental_metadata_attributes`. Printing it would bury the fields + /// that were chosen deliberately under three that never vary in usefulness. + #[test] + fn call_site_metadata_is_not_printed() { + let bytes = make_log_bytes_with_attrs( + "shutting down", + &[ + ( + "code.filepath", + any_value::Value::StringValue("src/main.rs".into()), + ), + ("code.lineno", any_value::Value::IntValue(1547)), + ( + "code.namespace", + any_value::Value::StringValue("meridian".into()), + ), + ( + "log.target", + any_value::Value::StringValue("meridian".into()), + ), + ("pid", any_value::Value::IntValue(99)), + ], + ); + let r = &decode_logs(&bytes).unwrap()[0]; + assert_eq!( + r.attributes, + vec![("pid".to_string(), "99".to_string())], + "only the emitter's own fields should survive; got {:?}", + r.attributes + ); + } + + /// A field declared on a span but never recorded arrives as an empty value. + /// Rendering `outcome=` costs width and carries nothing. + #[test] + fn unset_fields_are_omitted() { + let bytes = make_log_bytes_with_attrs( + "daemon_lifecycle.stop", + &[ + ("outcome", any_value::Value::StringValue(String::new())), + ("reason", any_value::Value::StringValue("quit".to_string())), + ], + ); + let r = &decode_logs(&bytes).unwrap()[0]; + assert_eq!( + r.attributes, + vec![("reason".to_string(), "quit".to_string())] + ); + } + #[test] fn decode_logs_round_trips_service_severity_and_body() { let bytes = make_log_bytes("test-svc", "WARN", "hello from the spool"); diff --git a/tray/src-tauri/src/daemon_lifecycle.rs b/tray/src-tauri/src/daemon_lifecycle.rs index 8a2323459..9ca58b572 100644 --- a/tray/src-tauri/src/daemon_lifecycle.rs +++ b/tray/src-tauri/src/daemon_lifecycle.rs @@ -922,5 +922,41 @@ mod tests { INSIDE the task, or a panic mid-stop leaves the exit held forever \ and the app cannot be quit at all; found: {spawn_body:?}" ); + + // `stop_for_quit`'s verdict must be FLUSHED before the process exits. + // + // `handle.exit` is `std::process::exit`: no destructors, and whatever + // the OTel batch processors are still holding dies with the process. + // What they are holding at that moment is the line describing how + // stopping the daemon went - `daemon stopped for quit`, `could not stop + // the daemon on quit`, or `exceeded its budget`. Those are the most + // useful records that exist for a corruption report, because quit is + // when the tray and the daemon are most likely to overlap on + // meridian.db, and every one of them was being discarded microseconds + // after being emitted. + // + // Ordering is asserted, not mere presence: a flush placed BEFORE + // `stop_for_quit` compiles, runs, logs nothing unusual, and preserves + // exactly the records that were never in danger while still losing the + // one that was. + let stop_pos = spawn_body + .find("stop_for_quit().await") + .expect("the spawned task must call stop_for_quit"); + let flush_pos = spawn_body + .find("observability::force_flush().await") + .expect( + "the spawned stop task must flush telemetry before exiting, or \ + stop_for_quit's outcome never reaches the spool", + ); + let exit_pos = spawn_body + .find("handle.exit(") + .expect("the spawned task must exit the app"); + assert!( + stop_pos < flush_pos && flush_pos < exit_pos, + "the telemetry flush must sit BETWEEN stop_for_quit and \ + handle.exit: before the stop it flushes a verdict that has not \ + been reached yet, and after the exit it does not run at all. \ + Found stop at {stop_pos}, flush at {flush_pos}, exit at {exit_pos}." + ); } } diff --git a/tray/src-tauri/src/lib.rs b/tray/src-tauri/src/lib.rs index dc134f10d..5a16ce644 100644 --- a/tray/src-tauri/src/lib.rs +++ b/tray/src-tauri/src/lib.rs @@ -1458,6 +1458,18 @@ pub fn run() { // phase. See [`daemon_lifecycle::HeldExitGuard`]. let _released = daemon_lifecycle::HeldExitGuard; daemon_lifecycle::stop_for_quit().await; + // Push `stop_for_quit`'s verdict to the spool BEFORE + // exiting. `handle.exit` is `std::process::exit`, so + // it runs no destructors and takes whatever the OTel + // batch processors are still holding with it — and + // what they are holding at this instant is the line + // that just described how stopping the daemon went. + // + // That line is the single most useful record for a + // corruption report (quit is when the tray and the + // daemon are most likely to overlap on meridian.db), + // and it was the one guaranteed never to survive. + meridian::observability::force_flush().await; // Immediately before the exit, so the re-entrant // `ExitRequested` this triggers is the one and only // one allowed through. From 215f99ab30f24d5939b6e6036875ce2449f10fae Mon Sep 17 00:00:00 2001 From: Akarsh Hegde Date: Wed, 26 Aug 2026 13:33:40 +0530 Subject: [PATCH 12/53] fix(coding-agent): move the summariser's cursor-agent prompt off argv onto stdin Same bug as the codex/claude fix already merged (#901): the coding-agent session summariser's cursor_agent.rs was still passing its full, always-multi-line prompt as a positional `cursor-agent -p ` argv argument. On Windows `cursor-agent` resolves to `cursor-agent.cmd` - a batch file, not a native exe - and Rust's std library refuses to spawn a `.bat`/`.cmd` target when an argument contains a newline (the CVE-2024-24576 "BatBadBut" fix), so every real call through this function failed to even spawn on Windows. The sibling hourly-pipeline backend (src/llm/cursor.rs::CursorBackend) was already fixed for exactly this in commit 4a83d98f (2026-07-24) - "confirmed live" per its own module doc - but the summariser's copy was never touched, identical to the codex/claude miss. This was found via the connectivity audit that followed #901 (github.com/Meridiona/meridian/issues/805, #841), which flagged it as the highest-confidence remaining gap: same fix, same file shape, already proven twice this session. No positional prompt on argv now; the prompt goes over stdin instead, matching CursorBackend's already-fixed shape exactly (`-p` as a bare flag). The stale "stdin support unprobed" comment is corrected - CursorBackend's own module doc already confirms it live. New tests pin: the prompt is genuinely multi-line (the premise the fix rests on), argv never carries a newline, no positional prompt is passed, and the safety-ladder flags still thread through correctly. --- .../summariser/cursor_agent.rs | 73 +++++++++++++++++-- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/src/coding_agent_session_ingest/summariser/cursor_agent.rs b/src/coding_agent_session_ingest/summariser/cursor_agent.rs index 667504b07..dc6cc0938 100644 --- a/src/coding_agent_session_ingest/summariser/cursor_agent.rs +++ b/src/coding_agent_session_ingest/summariser/cursor_agent.rs @@ -2,7 +2,7 @@ // // Run `cursor-agent` to summarise a Cursor session (symmetry with claude.rs / // codex.rs / copilot.rs — each agent's transcripts go to its own CLI, with no -// cross-engine fallback). The transcript rides in the prompt argument, and any +// cross-engine fallback). The transcript goes over stdin (see below), and any // failure leaves the row pending for a later drain (Failed → retried up to // primary_attempts, then left pending). // @@ -13,6 +13,19 @@ // Persistence probed: `-p` runs write to ~/.cursor/chats/, NOT the vscdb we // ingest from — so a summariser run cannot re-enter the indexer (the // SUMMARY_PROMPT_MARKER guard in sources/mod.rs backstops this anyway). +// +// # The whole prompt goes over stdin, not argv (Windows) +// +// It used to be `cursor-agent -p ` (positional argv). `cursor-agent` resolves to +// `cursor-agent.cmd` on Windows - a batch file, not a native exe - and Rust's std library +// refuses to spawn a `.bat`/`.cmd` target when an argument contains characters it cannot +// safely escape (the CVE-2024-24576 "BatBadBut" fix), notably embedded newlines. The prompt +// (instructions + transcript) is always multi-line, so every real call through this function +// failed to even spawn on Windows with `io::Error { InvalidInput, "batch file arguments are +// invalid" }`. `crate::llm::cursor::CursorBackend` (the hourly worklog pipeline / connectivity +// test) already moved off argv for exactly this reason, "confirmed live" per its own module +// doc - this applies the identical fix here, which had the identical bug all along. +// `cursor-agent -p` as a bare flag (no positional value) reads the whole prompt from stdin. use super::config::SummariserConfig; use super::prompts; @@ -74,6 +87,15 @@ pub async fn run_cursor_agent( .await } +/// The `cursor-agent -p` argv - no prompt in here, see the module doc. Split out so the +/// no-newline invariant this function exists to guarantee is directly testable. +fn cursor_agent_args(safety: Vec) -> Vec { + let mut args: Vec = vec!["-p".into()]; + args.extend(["--output-format".into(), "text".into()]); + args.extend(safety); + args +} + /// One `cursor-agent` attempt with the safety argv/environment chosen by the ladder. async fn run_once( prompt: &str, @@ -81,16 +103,14 @@ async fn run_once( env: Vec<(&'static str, String)>, cfg: &SummariserConfig, ) -> Result { - let mut args: Vec = vec!["-p".into(), prompt.to_string()]; - args.extend(["--output-format".into(), "text".into()]); - args.extend(safety); + let args = cursor_agent_args(safety); let env_refs: Vec<(&str, &str)> = env.iter().map(|(k, v)| (*k, v.as_str())).collect(); let cap = run_capture( "cursor-agent", &args, - "", // transcript is embedded in the prompt (stdin support unprobed) + prompt, // payload goes over stdin - see the module doc // See the note in llm/cursor.rs: a cwd outside $HOME so rule discovery cannot regress. &crate::llm::cursor_cli::neutral_workspace(), cfg.cursor_timeout_s, @@ -132,3 +152,46 @@ async fn run_once( } Ok(EngineOutput { summary }) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The premise the whole fix rests on: the instructions are sourced from a Markdown + /// rules file and are always multi-line. If `SKILL.md` ever became single-line, this + /// fix would no longer be guarding against anything real - this pins that it still is. + #[test] + fn the_instructions_prompt_is_multi_line() { + assert!(prompts::summary_instruction().contains('\n')); + } + + /// The regression this fix exists to close: `cursor-agent -p` argv must never carry the + /// prompt (or anything else newline-bearing) - that is exactly what makes Rust's std + /// refuse to spawn `cursor-agent.cmd` on Windows. A future edit that reintroduces the + /// prompt into `args` fails this test. + #[test] + fn cursor_agent_args_never_contains_a_newline() { + let args = cursor_agent_args(vec!["--allowed-tools".into(), String::new()]); + for arg in &args { + assert!(!arg.contains('\n'), "argv entry carries a newline: {arg:?}"); + } + } + + /// `-p` must be a bare flag - a positional value right after it would force the prompt + /// back through argv. + #[test] + fn cursor_agent_args_has_no_positional_prompt() { + let args = cursor_agent_args(vec![]); + assert_eq!(args[0], "-p"); + assert_eq!( + args[1], "--output-format", + "the second argv entry must be a flag, not a prompt" + ); + } + + #[test] + fn cursor_agent_args_carries_the_safety_flags() { + let args = cursor_agent_args(vec!["--force".into()]); + assert!(args.contains(&"--force".to_string())); + } +} From bd048b5269d8f97af0bfd19d2f645b6a5efafd1f Mon Sep 17 00:00:00 2001 From: Akarsh Hegde Date: Wed, 26 Aug 2026 13:46:18 +0530 Subject: [PATCH 13/53] fix(daemon): put a pid on the three stand-down WARNs These are the ONLY record of a stand-down that reaches central telemetry. The ship leg is WARN+ only, and `meridian daemon starting` - the line that carries the pid - is INFO, so it never egresses. A stand-down therefore arrived in central OO as an anonymous event that could not be tied to a process or correlated with anything around it: the same gap that made the 2026-08-25 investigation unresolvable. The `HeldByAnother` WARN in particular is a MEASUREMENT, not just a guard. It fires exactly when two daemons raced and the endpoint probe did not see it - the case that was previously both invisible and unguarded - so its rate across the fleet is the first direct evidence of how often this happens in the field rather than how often it could happen in principle. `as i64` for the same reason as everywhere else: a u32 ships as a string via record_debug and has to survive the string allowlist; an i64 is an IntValue and is kept unconditionally. --- src/main.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main.rs b/src/main.rs index e596a0e4a..fb9e51c91 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1087,8 +1087,18 @@ async fn main() -> Result<()> { // // The endpoint itself is OS-specific (a socket file on Unix, a named // pipe on Windows) — see `meridian::platform`. + // + // `pid` on this and the two stand-down logs below is not decoration. + // These WARNs are the ONLY record of a stand-down that reaches central + // telemetry: the redaction ship leg is WARN+ only, and `meridian daemon + // starting` — the line that would otherwise carry the pid — is INFO and + // never egresses. Without it a stand-down arrives as an anonymous event + // that cannot be tied to a process or correlated with anything around + // it, which is the same gap that made the 2026-08-25 investigation + // unresolvable. if meridian::platform::daemon_already_running().await { tracing::warn!( + pid = std::process::id() as i64, endpoint = %meridian::platform::endpoint_display(), "another meridian daemon already owns this data dir — exiting (single-instance guard)" ); @@ -1122,8 +1132,16 @@ async fn main() -> Result<()> { // Unavailable arm. let _single_instance_lock = match meridian::platform::acquire_single_instance_lock() { meridian::platform::LockOutcome::Acquired(guard) => Some(guard), + // This WARN is a MEASUREMENT as much as a stand-down. It fires exactly + // when two daemons raced and the probe above did not see it — the case + // that was previously invisible AND unguarded. Because it egresses to + // central telemetry, its rate across the fleet is the first direct + // evidence of how often this actually happens, rather than how often it + // could happen in principle. Rewording it is fine; dropping it or + // demoting it below WARN removes the only signal we have. meridian::platform::LockOutcome::HeldByAnother => { tracing::warn!( + pid = std::process::id() as i64, "another meridian daemon holds the single-instance lock for this data dir — exiting (lock)" ); return Ok(()); @@ -1138,6 +1156,7 @@ async fn main() -> Result<()> { // common outage. meridian::platform::LockOutcome::Unavailable(e) => { tracing::warn!( + pid = std::process::id() as i64, error = %e, // not-anyhow: a String the acquire already formatted with its full cause; there is no chain to walk "could not take the single-instance lock — continuing without it; \ the endpoint probe above remains the only guard this start has" From cd296a9dfc02bcbfb5fdca5483ddb5101440d3f6 Mon Sep 17 00:00:00 2001 From: Akarsh Hegde Date: Wed, 26 Aug 2026 14:21:36 +0530 Subject: [PATCH 14/53] fix(infra): stop an unknown argument from triggering a production deploy Self-found while re-reading #902 after merge, not from review. The flag handling I added there was two bare `if [ "$1" = "--flag" ]` blocks with no else, so ANY other argument fell straight through to the deploy. `--help`, `--dry-run`, `-n`, and a typo like `--selftest` all pushed config to the production gateway and restarted the stack. The two safest-sounding things a person types when unsure what a script does were the two most dangerous. Replaced with an explicit `case`: a deploy now requires exactly zero arguments, `--help` prints usage, and anything unrecognised exits 2 without touching the VM. Verified: --dry-run / --selftest / -n / --deploy all exit 2 with no gcloud call; --help exits 0; --self-test and --verify-only unchanged (verify-only still returns 401 on all four probes against the live gateway). --- scripts/deploy-gateway.sh | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/scripts/deploy-gateway.sh b/scripts/deploy-gateway.sh index 409698088..248b664aa 100644 --- a/scripts/deploy-gateway.sh +++ b/scripts/deploy-gateway.sh @@ -140,6 +140,42 @@ verify_public_endpoints_authenticate() { return "${failed}" } +# ── Argument dispatch ──────────────────────────────────────────────────────── +# +# Every argument is either recognised or an ERROR. This used to be two +# `if [ "$1" = "--flag" ]` blocks with no else, so ANY other argument fell +# straight through to the deploy: `--help`, `--dry-run`, `-n`, or a typo like +# `--selftest` all pushed config to the production gateway and restarted it. +# The two safest-sounding things a person types when they are unsure what a +# script does were the two most dangerous. +# +# A deploy now requires exactly zero arguments. Anything unrecognised prints +# usage and exits 2 without touching the VM. +case "${1:-}" in +--verify-only | --self-test | "") ;; +-h | --help) + cat <<-USAGE + usage: deploy-gateway.sh [--verify-only | --self-test] + + (no arguments) deploy ops/central-observability/ to the gateway VM, + then assert both public hostnames reject + unauthenticated callers + --verify-only run only that assertion, against the live gateway, + deploying nothing + --self-test offline check of the status classifier + + env: GATEWAY_VM GATEWAY_ZONE GATEWAY_PROJECT GATEWAY_DOMAIN + OO_UI_DOMAIN PROBE_TIMEOUT_S PROBE_INTERVAL_S + USAGE + exit 0 + ;; +*) + echo "deploy-gateway.sh: unknown argument '${1}'" >&2 + echo "run with --help for usage; a deploy takes NO arguments" >&2 + exit 2 + ;; +esac + # Run the auth assertion on its own, without deploying anything. Two uses: an # operator re-checking a gateway they did not just deploy, and testing a change # to the probes themselves — the alternative is running a production deploy to From e4e8e59954df73941721737779fea6a1bcfd947e Mon Sep 17 00:00:00 2001 From: adityaharishch Date: Wed, 26 Aug 2026 15:21:36 +0530 Subject: [PATCH 15/53] fix(sync): make the daemon the sole owner of the rotating Jira OAuth token An Atlassian OAuth refresh token is single-use and rotating: the old token dies the instant the new one is issued, so a lost response leaves the grant recoverable only inside a 10-minute window and permanently dead after it. That makes it a resource with exactly ONE safe writer. It had several - the daemon's poll loop, the tray in-process, and a fresh `meridian` CLI process per trigger. The advisory file lock meant to serialise them could not: its 10s timeout is shorter than the ~26s a refresh can take, and on timeout the code proceeded WITHOUT the lock. Only Atlassian's grace window prevented corruption. This is not hypothetical. A production install lost its Jira grant permanently when a timer-driven refresh POST at 18:26:55 straddled a 28-minute suspend; the retry was instant on wake at 18:55:29, but 18 minutes outside the window. Two changes make single-ownership structural rather than a matter of lock discipline: 1. An outbox (`pm_sync_requests`, migration 082). Producers write a coalescing request row; the daemon's watcher is the only consumer. The tray, and the `tasks-sync`/`pm-sync`/`plan-task-*`/`ticket-update`/`ticket-set-status`/ `worklog-generate` CLIs all ask instead of doing. The only remaining in-process sync is the fallback taken when no daemon is running at all, where there is no second writer to race. 2. An attended/unattended split (`Trigger`). Unattended code may USE a valid access token but must never MINT one: the clock-driven worklog sweep goes through `current_if_valid`, so an expired token defers instead of refreshing and a laptop shut overnight no longer raises a sync error every morning. Every refresh now sits immediately behind a human action. The standing timer is gone with it. The daemon's poll loop no longer syncs, so installs stop hitting their tracker every ~5 minutes forever. Syncing is now triggered only where it is needed: connecting a tracker, "Sync now", any board write, and the two screens that decide something from the whole task list - the daily plan (a ticket assigned an hour ago was absent from the list users pick from, not merely stale) and the retarget ticket picker (which had no refresh at all). Dashboard-open is deliberately NOT a trigger: a window opening is not evidence anyone is about to read the board. Worklog drafting needs no board refresh either, since matching reads the day's plan as its candidate pool. Also fixed while here: - `sync_freshness` is outcome-driven (`pm_sync_state.last_error`) instead of elapsed-time-driven, which was a guaranteed false positive once quiet periods became normal. - "Sync now" with the daemon stopped fell through to a request nobody would service; it now shells out to the CLI's own no-daemon fallback. - `meridian tasks-sync` exited 0 on a failed sync, so the tray's fallback reported success for a sync that did not happen. - Split `auto_generate.rs`, `pm_sync_requests.rs` and `meridian-oauth/jira.rs` to stay under the 500-line cap. NOT YET MANUALLY VERIFIED - see the PR description. Every automated gate is green (fmt, clippy -D warnings, cargo test --workspace, tsc, 915 UI tests, static export build), but the tray-to-daemon round trip has no automated coverage and migration 082 has only ever run against in-memory SQLite, never a real SQLCipher WAL database with two processes attached. --- meridian-core/src/lib.rs | 4 + meridian-core/src/pm_sync_requests/mod.rs | 279 ++++++++++++++ meridian-core/src/pm_sync_requests/tests.rs | 266 ++++++++++++++ meridian-oauth/src/{jira.rs => jira/mod.rs} | 102 +++--- meridian-oauth/src/jira/tests.rs | 121 ++++++ src/health/jira.rs | 113 +++++- src/intelligence/mod.rs | 113 +++++- src/intelligence/oauth/jira.rs | 89 ++++- src/intelligence/providers/jira/refresh.rs | 150 +++++--- src/intelligence/sync_delegate.rs | 320 ++++++++++++++++ src/intelligence/sync_requests.rs | 227 ++++++++++++ src/main.rs | 200 ++++++++-- src/migrations/082_pm_sync_requests.sql | 68 ++++ src/plan_tasks/create.rs | 36 +- src/plan_tasks/done.rs | 17 +- src/plan_tasks/edit.rs | 17 +- src/pm_worklog/auto_generate/mod.rs | 216 +++++++++++ .../sweep.rs} | 237 ++---------- tray/src-tauri/src/commands/integrations.rs | 28 +- tray/src-tauri/src/commands/tasks.rs | 345 +++++++++++++----- tray/src-tauri/src/lib.rs | 1 + ui/components/plan/PlanView.tsx | 31 +- .../timeline/WorklogTicketPicker.tsx | 16 +- .../timeline/settings/IntegrationsSection.tsx | 2 +- ui/lib/taskSync.ts | 56 +++ 25 files changed, 2575 insertions(+), 479 deletions(-) create mode 100644 meridian-core/src/pm_sync_requests/mod.rs create mode 100644 meridian-core/src/pm_sync_requests/tests.rs rename meridian-oauth/src/{jira.rs => jira/mod.rs} (85%) create mode 100644 meridian-oauth/src/jira/tests.rs create mode 100644 src/intelligence/sync_delegate.rs create mode 100644 src/intelligence/sync_requests.rs create mode 100644 src/migrations/082_pm_sync_requests.sql create mode 100644 src/pm_worklog/auto_generate/mod.rs rename src/pm_worklog/{auto_generate.rs => auto_generate/sweep.rs} (53%) diff --git a/meridian-core/src/lib.rs b/meridian-core/src/lib.rs index 9653d4712..fa07d5253 100644 --- a/meridian-core/src/lib.rs +++ b/meridian-core/src/lib.rs @@ -44,6 +44,10 @@ pub mod settings; /// Notification delivery policy + native pending queue (ported from lib/notifications.ts). pub mod notifications; +/// Single-owner PM sync request outbox (migration 082) - producers ask, the daemon +/// alone services them, so the rotating Jira OAuth token has exactly one writer. +pub mod pm_sync_requests; + /// The `~/.meridian/plan_auto_opened` marker format — written by the tray's /// daily planner auto-open, read by the daemon's plan-nudge hold-back. pub mod plan_marker; diff --git a/meridian-core/src/pm_sync_requests/mod.rs b/meridian-core/src/pm_sync_requests/mod.rs new file mode 100644 index 000000000..ed070c6b5 --- /dev/null +++ b/meridian-core/src/pm_sync_requests/mod.rs @@ -0,0 +1,279 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +//! Single-owner PM sync: the request side of the outbox (`pm_sync_requests`, +//! migration 082). +//! +//! # Why sync is a request instead of an action +//! +//! An Atlassian OAuth refresh token is single-use and rotating. The old token dies +//! the instant the new one is issued, so a lost response leaves the grant +//! recoverable only inside a 10-minute window and permanently dead after it. That +//! makes the token a resource with exactly ONE safe writer. +//! +//! It had several: the tray refreshed in-process, the daemon refreshed on its poll +//! loop, and the tray spawned fresh `meridian pm-sync` / `tasks-sync` processes that +//! each refreshed too. The advisory file lock meant to serialise them could not +//! actually do it - its 10 s timeout is shorter than the ~26 s a refresh can take +//! (3 attempts x 8 s plus backoff), and on timeout the code proceeded WITHOUT the +//! lock rather than backing off. So two processes could spend the same token, and +//! the only thing preventing corruption was Atlassian's grace window handing the +//! loser the current pair. +//! +//! Producers now write a row here and the **daemon is the sole consumer**, so the +//! credential is held by one process by construction rather than by lock discipline. +//! +//! # Who calls this +//! - Producers: `tray/src-tauri/src/commands/tasks.rs` (window opens, tracker +//! connect, "Sync now"), and the `meridian tasks-sync` / `pm-sync` CLIs when a +//! daemon is running. +//! - Consumer: the daemon's sync-request watcher (`src/intelligence/sync_requests.rs`). +//! +//! # Related +//! - [`crate::notifications`] - the outbox pattern this mirrors. + +use anyhow::{Context, Result}; +use sqlx::SqlitePool; + +/// The all-providers request every current producer writes. A specific provider +/// name scopes a request to one board, reserved for a future caller that needs it. +pub const ALL_PROVIDERS: &str = "*"; + +/// Whether the daemon should honour the per-provider staleness window or bypass it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncMode { + /// Honour the staleness window - the cheap common case (a window opening). + Gated, + /// Bypass it: the user explicitly asked (connected a tracker, pressed "Sync + /// now", ran a CLI). + Force, +} + +impl SyncMode { + /// The stored discriminant. Kept as text so the row is readable in `sqlite3` + /// during support work. + pub fn as_str(self) -> &'static str { + match self { + SyncMode::Gated => "gated", + SyncMode::Force => "force", + } + } + + /// Parse a stored discriminant, defaulting to the SAFER option. An unknown or + /// corrupt value must never silently become a `Force` that bypasses the + /// staleness gate and hammers the provider's API. + pub fn from_str_or_gated(s: &str) -> Self { + match s { + "force" => SyncMode::Force, + _ => SyncMode::Gated, + } + } +} + +/// One pending request, as claimed by the daemon. +#[derive(Debug, Clone)] +pub struct SyncRequest { + pub provider: String, + pub mode: SyncMode, + pub reason: String, +} + +/// Ask the daemon to sync PM tasks. Idempotent and coalescing: repeated calls +/// collapse into the single pending row rather than queueing, so opening the +/// dashboard ten times means "a sync is wanted", not ten syncs. +/// +/// `mode` **escalates only**. A `Force` landing on a pending `Gated` upgrades it, +/// because a user who just connected a tracker must not have that downgraded by a +/// passing window focus; a `Gated` landing on a pending `Force` leaves the `Force` +/// intact. Writing a new request also clears any previous completion stamps, so the +/// row unambiguously represents work still to do. +/// +/// `reason` is a producer tag for tracing only (`"dashboard_open"`, +/// `"token_connected"`). Never pass user content - it is read back into logs. +#[tracing::instrument(skip(pool))] +pub async fn request( + pool: &SqlitePool, + provider: &str, + mode: SyncMode, + reason: &str, +) -> Result<()> { + sqlx::query( + "INSERT INTO pm_sync_requests + (provider, mode, reason, requested_at, claimed_at, completed_at, error, synced_count) + VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), NULL, NULL, NULL, NULL) + ON CONFLICT(provider) DO UPDATE SET + -- Escalate to 'force', never back down from it while still pending. + -- + -- The `completed_at IS NULL` half is load-bearing: the row is kept after a + -- sync finishes (so \"Sync now\" can read its result), so without it a + -- SPENT 'force' would be inherited forever and every later gated request + -- would silently escalate. One tracker connect would then make every + -- planner open bypass the staleness gate and hit the provider for real - + -- reinstating the constant polling this whole design removes, and + -- multiplying exactly the token refreshes it exists to reduce. + mode = CASE + WHEN excluded.mode = 'force' + OR (pm_sync_requests.mode = 'force' + AND pm_sync_requests.completed_at IS NULL) + THEN 'force' + ELSE excluded.mode + END, + reason = excluded.reason, + requested_at = excluded.requested_at, + -- A fresh request re-opens the row: drop the in-flight and completion + -- marks so the watcher sees pending work again. + claimed_at = NULL, + completed_at = NULL, + error = NULL, + synced_count = NULL", + ) + .bind(provider) + .bind(mode.as_str()) + .bind(reason) + .execute(pool) + .await + .context("writing a PM sync request")?; + tracing::debug!(provider, mode = mode.as_str(), reason, "PM sync requested"); + Ok(()) +} + +/// Claim the pending request for `provider`, if there is one, marking it in-flight +/// so a second watcher tick can't pick up the same work. +/// +/// The claim is a conditional UPDATE (`WHERE claimed_at IS NULL AND completed_at IS +/// NULL`) rather than a read-then-write, so two daemons racing on the same file - +/// which the single-instance guard makes unlikely but not impossible during a +/// restart overlap - cannot both claim it. SQLite serialises the statement, so +/// exactly one sees a non-zero `rows_affected`. +#[tracing::instrument(skip(pool))] +pub async fn claim(pool: &SqlitePool, provider: &str) -> Result> { + let claimed = sqlx::query( + "UPDATE pm_sync_requests + SET claimed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE provider = ? + AND claimed_at IS NULL + AND completed_at IS NULL", + ) + .bind(provider) + .execute(pool) + .await + .context("claiming a PM sync request")?; + + if claimed.rows_affected() == 0 { + return Ok(None); + } + + let row: Option<(String, String)> = + sqlx::query_as("SELECT mode, reason FROM pm_sync_requests WHERE provider = ?") + .bind(provider) + .fetch_optional(pool) + .await + .context("reading the claimed PM sync request")?; + + Ok(row.map(|(mode, reason)| SyncRequest { + provider: provider.to_string(), + mode: SyncMode::from_str_or_gated(&mode), + reason, + })) +} + +/// Record the outcome of a serviced request in place. The row is deliberately kept +/// rather than deleted so `"Sync now"` can read a real result without holding the +/// credential, and so support has a content-free view of the last attempt. +/// +/// Writes only if the row is still the one that was claimed. The guard is +/// **`claimed_at IS NOT NULL`**, and that specific predicate is the whole point: +/// [`request`] resets `claimed_at` to `NULL`, so a request that arrived mid-sync +/// makes this UPDATE match nothing. Guarding on `completed_at IS NULL` alone would +/// NOT work - the fresh request leaves that NULL too, so the older sync's outcome +/// would stamp the new request as done without it ever being serviced, and the +/// user's "Sync now" would report success for a sync that never ran. +#[tracing::instrument(skip(pool))] +pub async fn complete( + pool: &SqlitePool, + provider: &str, + synced_count: Option, + error: Option<&str>, +) -> Result<()> { + sqlx::query( + "UPDATE pm_sync_requests + SET completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), + error = ?, + synced_count = ? + WHERE provider = ? + AND claimed_at IS NOT NULL + AND completed_at IS NULL", + ) + .bind(error) + .bind(synced_count) + .bind(provider) + .execute(pool) + .await + .context("completing a PM sync request")?; + Ok(()) +} + +/// Release claims left in flight by a daemon that died mid-sync, so the request +/// becomes claimable again. Call once at watcher startup. +/// +/// Without this a crash, a `meridian restart`, or a SIGKILL between [`claim`] and +/// [`complete`] strands the row **claimed but never completed** - and since [`claim`] +/// requires `claimed_at IS NULL`, no future tick would ever pick it up. PM sync +/// would then be silently dead until the next new request happened to reset the row. +/// Same reasoning as the daemon's `cleanup_incomplete_runs` for partial ETL runs. +/// +/// Only ever widens what is claimable, so it is safe to run unconditionally on every +/// boot: a genuinely in-flight sync cannot exist yet, because the only consumer is +/// the watcher this runs before. +#[tracing::instrument(skip(pool))] +pub async fn reset_stale_claims(pool: &SqlitePool) -> Result { + let res = sqlx::query( + "UPDATE pm_sync_requests + SET claimed_at = NULL + WHERE claimed_at IS NOT NULL + AND completed_at IS NULL", + ) + .execute(pool) + .await + .context("resetting stale PM sync request claims")?; + let n = res.rows_affected(); + if n > 0 { + tracing::info!( + reset = n, + "released PM sync claims stranded by a previous daemon exit" + ); + } + Ok(n) +} + +/// The outcome of the last request for `provider`, for a producer that wants to +/// show one ("Sync now"). `None` while the request is still pending or in flight, +/// so a caller can poll this until it turns `Some`. +pub async fn outcome(pool: &SqlitePool, provider: &str) -> Result> { + let row: Option<(Option, Option, Option)> = sqlx::query_as( + "SELECT completed_at, error, synced_count FROM pm_sync_requests WHERE provider = ?", + ) + .bind(provider) + .fetch_optional(pool) + .await + .context("reading the PM sync outcome")?; + + Ok(match row { + Some((Some(_completed), error, synced_count)) => Some(SyncOutcome { + error, + synced_count, + }), + // No row, or a row still pending / in flight. + _ => None, + }) +} + +/// A completed request's result. +#[derive(Debug, Clone)] +pub struct SyncOutcome { + /// `None` on success, the failure detail otherwise. + pub error: Option, + /// Tasks refreshed, when the daemon reported a count. + pub synced_count: Option, +} + +#[cfg(test)] +mod tests; diff --git a/meridian-core/src/pm_sync_requests/tests.rs b/meridian-core/src/pm_sync_requests/tests.rs new file mode 100644 index 000000000..2bf861c66 --- /dev/null +++ b/meridian-core/src/pm_sync_requests/tests.rs @@ -0,0 +1,266 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +//! Tests for the PM sync request outbox. +//! +//! Split out of `mod.rs` purely for the 500-line file cap; they are the module's own +//! unit tests and belong to it. Each one pins a race or a policy that is invisible +//! from the SQL alone - read them alongside the doc comment on the function they +//! exercise. + +use super::*; +use sqlx::sqlite::SqliteConnectOptions; +use std::str::FromStr; + +async fn db() -> SqlitePool { + let opts = SqliteConnectOptions::from_str("sqlite::memory:") + .unwrap() + .create_if_missing(true); + let pool = SqlitePool::connect_with(opts).await.unwrap(); + sqlx::query( + "CREATE TABLE pm_sync_requests ( + provider TEXT NOT NULL PRIMARY KEY, + mode TEXT NOT NULL DEFAULT 'gated', + reason TEXT NOT NULL DEFAULT '', + requested_at TEXT NOT NULL, + claimed_at TEXT, + completed_at TEXT, + error TEXT, + synced_count INTEGER + )", + ) + .execute(&pool) + .await + .unwrap(); + pool +} + +/// Repeated requests must COALESCE into one pending row. Ten planner opens +/// mean "a sync is wanted", not ten syncs. +#[tokio::test] +async fn repeated_requests_coalesce_into_one_row() { + let pool = db().await; + for _ in 0..10 { + request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") + .await + .unwrap(); + } + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pm_sync_requests") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(n, 1); +} + +/// A user action must not be downgraded by a passing window focus. +#[tokio::test] +async fn force_survives_a_later_gated_request() { + let pool = db().await; + request(&pool, ALL_PROVIDERS, SyncMode::Force, "token_connected") + .await + .unwrap(); + request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") + .await + .unwrap(); + + let req = claim(&pool, ALL_PROVIDERS).await.unwrap().expect("pending"); + assert_eq!(req.mode, SyncMode::Force); +} + +/// ...and a user action must be able to escalate a pending gated request. +#[tokio::test] +async fn gated_escalates_to_force() { + let pool = db().await; + request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") + .await + .unwrap(); + request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") + .await + .unwrap(); + + let req = claim(&pool, ALL_PROVIDERS).await.unwrap().expect("pending"); + assert_eq!(req.mode, SyncMode::Force); +} + +/// A *spent* force must NOT be inherited. The row survives completion so "Sync +/// now" can read its result, so escalation has to be scoped to a still-pending +/// row - otherwise one tracker connect leaves `mode = 'force'` set forever and +/// every later planner open bypasses the staleness gate and hits the provider +/// for real, multiplying the token refreshes this design exists to reduce. +#[tokio::test] +async fn a_completed_force_does_not_escalate_the_next_gated_request() { + let pool = db().await; + request(&pool, ALL_PROVIDERS, SyncMode::Force, "token_connected") + .await + .unwrap(); + claim(&pool, ALL_PROVIDERS).await.unwrap(); + complete(&pool, ALL_PROVIDERS, Some(4), None).await.unwrap(); + + // A later window open wants the cheap, gated behaviour. + request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") + .await + .unwrap(); + + let req = claim(&pool, ALL_PROVIDERS).await.unwrap().expect("pending"); + assert_eq!( + req.mode, + SyncMode::Gated, + "a spent force must not escalate later gated requests" + ); +} + +/// The in-flight case still escalates: a force that is claimed but not completed +/// will have its outcome discarded by `complete`'s guard and be re-serviced, so +/// the force intent must survive into that re-run. +#[tokio::test] +async fn an_in_flight_force_still_survives_a_gated_request() { + let pool = db().await; + request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") + .await + .unwrap(); + claim(&pool, ALL_PROVIDERS).await.unwrap(); + + request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") + .await + .unwrap(); + + let req = claim(&pool, ALL_PROVIDERS).await.unwrap().expect("pending"); + assert_eq!(req.mode, SyncMode::Force); +} + +/// A claim is exclusive: the second attempt sees nothing, so two watcher ticks +/// can never run the same sync twice. +#[tokio::test] +async fn claim_is_exclusive() { + let pool = db().await; + request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") + .await + .unwrap(); + assert!(claim(&pool, ALL_PROVIDERS).await.unwrap().is_some()); + assert!( + claim(&pool, ALL_PROVIDERS).await.unwrap().is_none(), + "a claimed request must not be claimable again" + ); +} + +/// Nothing pending is a quiet `None`, not an error - the watcher ticks on this +/// constantly. +#[tokio::test] +async fn claim_with_no_request_is_none() { + let pool = db().await; + assert!(claim(&pool, ALL_PROVIDERS).await.unwrap().is_none()); +} + +/// A daemon killed between claim and complete must not strand PM sync forever. +/// Without the reset the row stays claimed, `claim` requires `claimed_at IS +/// NULL`, and no future tick could ever service it. +#[tokio::test] +async fn stale_claims_are_released_on_startup() { + let pool = db().await; + request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") + .await + .unwrap(); + claim(&pool, ALL_PROVIDERS).await.unwrap(); + // ... daemon dies here, no `complete` ever runs. + + assert!( + claim(&pool, ALL_PROVIDERS).await.unwrap().is_none(), + "precondition: a stranded claim blocks re-claiming" + ); + + assert_eq!(reset_stale_claims(&pool).await.unwrap(), 1); + + let req = claim(&pool, ALL_PROVIDERS) + .await + .unwrap() + .expect("the request must be serviceable again after the reset"); + assert_eq!(req.mode, SyncMode::Force); +} + +/// The reset must not disturb a request that already completed - that would +/// re-run finished work on every daemon boot. +#[tokio::test] +async fn reset_leaves_completed_requests_alone() { + let pool = db().await; + request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") + .await + .unwrap(); + claim(&pool, ALL_PROVIDERS).await.unwrap(); + complete(&pool, ALL_PROVIDERS, Some(2), None).await.unwrap(); + + assert_eq!(reset_stale_claims(&pool).await.unwrap(), 0); + assert!( + claim(&pool, ALL_PROVIDERS).await.unwrap().is_none(), + "a completed request must stay completed" + ); +} + +/// The outcome is invisible until the daemon finishes, so a producer polling it +/// can tell "still working" from "done". +#[tokio::test] +async fn outcome_is_none_until_completed() { + let pool = db().await; + request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") + .await + .unwrap(); + assert!(outcome(&pool, ALL_PROVIDERS).await.unwrap().is_none()); + + claim(&pool, ALL_PROVIDERS).await.unwrap(); + assert!( + outcome(&pool, ALL_PROVIDERS).await.unwrap().is_none(), + "in-flight must still read as pending" + ); + + complete(&pool, ALL_PROVIDERS, Some(7), None).await.unwrap(); + let out = outcome(&pool, ALL_PROVIDERS).await.unwrap().expect("done"); + assert_eq!(out.synced_count, Some(7)); + assert!(out.error.is_none()); +} + +/// A failure is reported, not swallowed. +#[tokio::test] +async fn outcome_carries_the_error() { + let pool = db().await; + request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") + .await + .unwrap(); + claim(&pool, ALL_PROVIDERS).await.unwrap(); + complete(&pool, ALL_PROVIDERS, None, Some("401 unauthorized")) + .await + .unwrap(); + + let out = outcome(&pool, ALL_PROVIDERS).await.unwrap().expect("done"); + assert_eq!(out.error.as_deref(), Some("401 unauthorized")); +} + +/// THE RACE THIS GUARDS: a request arriving mid-sync resets the row, and the +/// older sync's outcome must NOT stamp it complete - that would mark the new +/// request done without ever servicing it. +#[tokio::test] +async fn completion_does_not_clobber_a_request_that_arrived_mid_sync() { + let pool = db().await; + request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") + .await + .unwrap(); + claim(&pool, ALL_PROVIDERS).await.unwrap(); + + // A new request lands while the first sync is still running. + request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") + .await + .unwrap(); + + // The in-flight sync finishes and tries to report. This must be a NO-OP: + // the new request cleared `claimed_at`, so the guard rejects it. + complete(&pool, ALL_PROVIDERS, Some(3), None).await.unwrap(); + + assert!( + outcome(&pool, ALL_PROVIDERS).await.unwrap().is_none(), + "the older sync's outcome must NOT mark the new request complete - \ + that would report success for a sync that never ran" + ); + + let req = claim(&pool, ALL_PROVIDERS) + .await + .unwrap() + .expect("the mid-sync request must still be pending"); + assert_eq!(req.mode, SyncMode::Force); + assert_eq!(req.reason, "sync_now"); +} diff --git a/meridian-oauth/src/jira.rs b/meridian-oauth/src/jira/mod.rs similarity index 85% rename from meridian-oauth/src/jira.rs rename to meridian-oauth/src/jira/mod.rs index 198901125..37ff9086c 100644 --- a/meridian-oauth/src/jira.rs +++ b/meridian-oauth/src/jira/mod.rs @@ -236,9 +236,59 @@ pub async fn login(client_id: &str, client_secret: &str, port: u16) -> Result Option { + let t = store::load("jira").ok()?; + if t.is_expired(now_unix(), EXPIRY_SKEW_SECS) { + tracing::debug!( + "jira access token expired - unattended caller deferring rather than refreshing" + ); + return None; + } + Some(t) +} + /// Load the stored tokens, refreshing the access token if it's within 120 s of /// expiry. Persists the rotated refresh token. Returns ready-to-use tokens. /// +/// **Only call this on the path of a real user action.** Spending the rotating +/// refresh token from a background timer is what permanently kills a grant when +/// the machine suspends mid-request; see [`current_if_valid`] for the full +/// reasoning and the unattended alternative. +/// /// Refreshes are serialised on TWO levels so the rotating refresh token is never /// double-spent: a static mutex within this process, and an advisory FILE lock /// ([`store::lock_provider`]) across every Meridian process. After taking the file @@ -250,7 +300,7 @@ pub async fn ensure_fresh() -> Result { // Fast path: a still-fresh token needs neither a refresh nor the file lock. let t = store::load("jira")?; - if !t.is_expired(now_unix(), 120) { + if !t.is_expired(now_unix(), EXPIRY_SKEW_SECS) { return Ok(t); } @@ -273,7 +323,7 @@ pub async fn ensure_fresh() -> Result { // adopt their token instead of refreshing again with the dead one. This // double-check is what actually breaks the race. let mut t = store::load("jira")?; - if !t.is_expired(now_unix(), 120) { + if !t.is_expired(now_unix(), EXPIRY_SKEW_SECS) { tracing::debug!("jira token already refreshed by another process — adopting it"); return Ok(t); } @@ -370,50 +420,4 @@ impl JiraReqCtx { } #[cfg(test)] -mod tests { - use super::*; - - fn oauth_ctx() -> JiraReqCtx { - JiraReqCtx::OAuth { - token: "tok".into(), - cloud_id: "cloud-xyz".into(), - site_url: "https://acme.atlassian.net".into(), - } - } - - fn basic_ctx() -> JiraReqCtx { - JiraReqCtx::Basic { - base_url: "https://acme.atlassian.net/".into(), - email: "a@b.com".into(), - api_token: "tok".into(), - } - } - - #[test] - fn oauth_api_url_uses_gateway() { - assert_eq!( - oauth_ctx().api_url("/rest/api/3/search/jql"), - "https://api.atlassian.com/ex/jira/cloud-xyz/rest/api/3/search/jql" - ); - } - - #[test] - fn basic_api_url_uses_site_and_trims_slash() { - assert_eq!( - basic_ctx().api_url("/rest/api/3/search/jql"), - "https://acme.atlassian.net/rest/api/3/search/jql" - ); - } - - #[test] - fn browse_url_uses_site_in_both_modes() { - assert_eq!( - oauth_ctx().browse_url("KAN-1"), - "https://acme.atlassian.net/browse/KAN-1" - ); - assert_eq!( - basic_ctx().browse_url("KAN-1"), - "https://acme.atlassian.net/browse/KAN-1" - ); - } -} +mod tests; diff --git a/meridian-oauth/src/jira/tests.rs b/meridian-oauth/src/jira/tests.rs new file mode 100644 index 000000000..7293dcd30 --- /dev/null +++ b/meridian-oauth/src/jira/tests.rs @@ -0,0 +1,121 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +//! Tests for the Jira OAuth flow and token handling. +//! +//! Split out of `mod.rs` for the 500-line file cap; they are this module's own unit +//! tests. The `current_if_valid` cases pin the attended/unattended split - the rule +//! that unattended code may USE a valid access token but must never MINT one, which +//! is what stops a background refresh being destroyed by a laptop suspend. + +use super::*; + +fn oauth_ctx() -> JiraReqCtx { + JiraReqCtx::OAuth { + token: "tok".into(), + cloud_id: "cloud-xyz".into(), + site_url: "https://acme.atlassian.net".into(), + } +} + +fn basic_ctx() -> JiraReqCtx { + JiraReqCtx::Basic { + base_url: "https://acme.atlassian.net/".into(), + email: "a@b.com".into(), + api_token: "tok".into(), + } +} + +#[test] +fn oauth_api_url_uses_gateway() { + assert_eq!( + oauth_ctx().api_url("/rest/api/3/search/jql"), + "https://api.atlassian.com/ex/jira/cloud-xyz/rest/api/3/search/jql" + ); +} + +#[test] +fn basic_api_url_uses_site_and_trims_slash() { + assert_eq!( + basic_ctx().api_url("/rest/api/3/search/jql"), + "https://acme.atlassian.net/rest/api/3/search/jql" + ); +} + +#[test] +fn browse_url_uses_site_in_both_modes() { + assert_eq!( + oauth_ctx().browse_url("KAN-1"), + "https://acme.atlassian.net/browse/KAN-1" + ); + assert_eq!( + basic_ctx().browse_url("KAN-1"), + "https://acme.atlassian.net/browse/KAN-1" + ); +} + +/// Seed a jira token store under a private `$HOME` with `expires_at` at +/// `now + offset_secs`. Returns the guard that keeps `$HOME` stable for the +/// duration of the test. +fn seed_store(tag: &str, offset_secs: i64) -> std::sync::MutexGuard<'static, ()> { + let guard = crate::env_test_guard(); + let dir = std::env::temp_dir().join(format!("meridian_oauth_civ_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::env::set_var("HOME", &dir); + store::save(&OAuthTokens { + provider: "jira".into(), + client_id: "cid".into(), + access_token: "access".into(), + refresh_token: "refresh".into(), + expires_at: now_unix() + offset_secs, + scopes: String::new(), + cloud_id: "cloud-xyz".into(), + site_url: "https://acme.atlassian.net".into(), + }) + .expect("seeding the token store should succeed"); + guard +} + +/// A comfortably-valid access token is handed straight back, so an unattended +/// sweep can still refresh the ticket cache without touching the network. +#[test] +fn current_if_valid_returns_a_live_token() { + let _g = seed_store("live", 3600); + let t = current_if_valid().expect("a token an hour from expiry must be usable"); + assert_eq!(t.access_token, "access"); + assert_eq!(t.cloud_id, "cloud-xyz"); +} + +/// THE POINT OF THE FUNCTION: an expired token yields `None` rather than +/// triggering a refresh. Spending the rotating refresh token unattended is what +/// permanently kills a grant when the machine suspends mid-POST, so a +/// clock-driven caller must defer instead. +#[test] +fn current_if_valid_refuses_to_mint_when_expired() { + let _g = seed_store("expired", -10); + assert!( + current_if_valid().is_none(), + "an expired token must NOT be returned - the caller would use it and 401, \ + and the whole point is to defer to the next attended request" + ); +} + +/// The skew is applied, not just raw expiry: a token inside the margin counts as +/// expired so a request can't be issued with one that dies mid-flight. +#[test] +fn current_if_valid_applies_the_expiry_skew() { + let _g = seed_store("skew", EXPIRY_SKEW_SECS - 10); + assert!( + current_if_valid().is_none(), + "a token inside the {EXPIRY_SKEW_SECS}s skew must be treated as expired" + ); +} + +/// No store at all is a `None`, never a panic or an error — an install that has +/// never connected Jira must sweep quietly. +#[test] +fn current_if_valid_handles_a_missing_store() { + let _g = crate::env_test_guard(); + let dir = std::env::temp_dir().join(format!("meridian_oauth_civ_none_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::env::set_var("HOME", &dir); + assert!(current_if_valid().is_none()); +} diff --git a/src/health/jira.rs b/src/health/jira.rs index ac4ec38a6..23624db4e 100644 --- a/src/health/jira.rs +++ b/src/health/jira.rs @@ -5,6 +5,16 @@ // collapses both into a silent warn. Sync freshness + candidate count come from // meridian.db (content-free). Creds are read from the env (loaded via dotenv at // startup), so this works without reaching into Config internals. +// +// `sync_freshness` is outcome-driven (`pm_sync_state.last_error`), not +// elapsed-time-driven. Syncing is on-demand now (the daily plan, the match-to-ticket +// picker, connecting a tracker, a board write, manual "Sync now") rather than a +// background poller ticking every ~5 minutes forever — so a quiet period with no sync +// attempt is normal, not a symptom. A laptop shut overnight is the everyday case: it +// wakes with a cache many hours old and nothing wrong. `last_error` reflects the actual outcome of the last attempted fetch +// (set by `intelligence::providers::record_sync_failure`, cleared by +// `clear_sync_error` on the next success), which stays correct however long that +// was ago. use crate::config::Config; use crate::health::Check; @@ -12,9 +22,6 @@ use crate::intelligence::oauth::{jira as oauth_jira, store as oauth_store}; use sqlx::SqlitePool; use std::time::Duration; -/// Cache older than this (2× the 30-min sync interval) ⇒ fetch likely failing. -const SYNC_STALE_SECS: f64 = 3600.0; - pub async fn checks(_cfg: &Config, pool: Option<&SqlitePool>) -> Vec { let mut out = Vec::new(); @@ -133,28 +140,29 @@ async fn classify_auth(send: reqwest::Result) -> Check { } async fn sync_freshness(pool: &SqlitePool) -> Check { - match sqlx::query_scalar::<_, Option>( - "SELECT (julianday('now') - julianday(MAX(last_synced_at))) * 86400.0 + match sqlx::query_as::<_, (Option, Option)>( + "SELECT last_error, (julianday('now') - julianday(last_synced_at)) * 86400.0 FROM pm_sync_state WHERE provider = 'jira'", ) - .fetch_one(pool) + .fetch_optional(pool) .await { - Ok(Some(age)) if age > SYNC_STALE_SECS => Check::warn( - "ticket sync", - "L3", - format!( - "cache {:.0}m stale — fetch may be failing silently", - age / 60.0 - ), - ) - .with_remedy("check the auth row above; the daemon refreshes every 30m"), - Ok(Some(age)) => Check::ok( + // A recorded failure is the real signal — the last attempted fetch didn't + // work, regardless of how long ago that was. Never a warn from elapsed time + // alone: a long quiet period between on-demand syncs is expected now. + Ok(Some((Some(err), _))) => { + Check::warn("ticket sync", "L3", format!("sync failing: {err}")).with_remedy( + "check the auth row above, or Reconnect Jira in Settings - Integrations", + ) + } + Ok(Some((None, Some(age)))) => Check::ok( "ticket sync", "L3", - format!("fresh ({:.0}m ago)", age / 60.0), + format!("last synced {:.0}m ago, no errors", age / 60.0), ), - Ok(None) => Check::info("ticket sync", "L3", "no Jira sync recorded yet"), + Ok(Some((None, None))) | Ok(None) => { + Check::info("ticket sync", "L3", "no Jira sync recorded yet") + } Err(e) => Check::warn( "ticket sync", "L3", @@ -188,3 +196,72 @@ async fn candidate_count(pool: &SqlitePool) -> Check { ), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::health::Severity; + use sqlx::sqlite::SqliteConnectOptions; + use std::str::FromStr; + + async fn make_db() -> SqlitePool { + let opts = SqliteConnectOptions::from_str("sqlite::memory:") + .unwrap() + .create_if_missing(true); + let pool = SqlitePool::connect_with(opts).await.unwrap(); + sqlx::migrate!("src/migrations").run(&pool).await.unwrap(); + pool + } + + async fn seed(pool: &SqlitePool, synced_modifier: &str, last_error: Option<&str>) { + sqlx::query( + "INSERT INTO pm_sync_state (provider, last_synced_at, last_error) + VALUES ('jira', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ?), ?)", + ) + .bind(synced_modifier) + .bind(last_error) + .execute(pool) + .await + .unwrap(); + } + + /// The false positive this rewrite exists to fix: a sync that hasn't run in + /// hours, with no recorded error, must NOT warn — on-demand syncing means a + /// long quiet period is expected, not a symptom. + #[tokio::test] + async fn sync_freshness_ok_when_stale_but_no_error() { + let pool = make_db().await; + seed(&pool, "-6 hours", None).await; + + let check = sync_freshness(&pool).await; + + assert_eq!(check.severity, Severity::Ok, "detail was: {}", check.detail); + } + + /// A recorded fetch failure is the real signal, regardless of how recent it is. + #[tokio::test] + async fn sync_freshness_warns_on_recorded_error() { + let pool = make_db().await; + seed(&pool, "-2 minutes", Some("401 unauthorized")).await; + + let check = sync_freshness(&pool).await; + + assert_eq!(check.severity, Severity::Warn); + assert!( + check.detail.contains("401 unauthorized"), + "detail was: {}", + check.detail + ); + } + + /// A tracker that has never synced (fresh connect, or one that's never + /// fetched successfully) reports as informational, not a warning. + #[tokio::test] + async fn sync_freshness_info_when_never_synced() { + let pool = make_db().await; + + let check = sync_freshness(&pool).await; + + assert_eq!(check.severity, Severity::Info); + } +} diff --git a/src/intelligence/mod.rs b/src/intelligence/mod.rs index 269728fd4..491d0415c 100644 --- a/src/intelligence/mod.rs +++ b/src/intelligence/mod.rs @@ -3,14 +3,37 @@ pub mod oauth; pub mod providers; pub mod session_categorizer; +/// Producer side of the `pm_sync_requests` outbox for short-lived CLI processes — how +/// `plan-task-*`, `ticket-update` and `worklog-generate` refresh the board without +/// becoming a second writer of the rotating Jira OAuth token. +pub mod sync_delegate; +/// Daemon-side consumer of the `pm_sync_requests` outbox — the reason the daemon is +/// the only process that ever holds the rotating Jira OAuth token. +pub mod sync_requests; pub mod task_triage; pub mod ticket_update; use anyhow::Result; use sqlx::SqlitePool; +use std::sync::OnceLock; +use tokio::sync::Mutex; use crate::config::{Config, PmProviderConfig}; +/// In-process serialisation for [`run_pm_sync`]. Now that syncing is triggered from +/// several independent on-demand call sites (the daily plan, worklog drafting sweep, +/// `meridian pm-sync`) instead of one single poll-loop tick, two of them can land in +/// the same instant — e.g. opening the planner while a drafting sweep is mid-flight. +/// Both would otherwise read `pm_sync_state` as stale and fire a real fetch +/// concurrently, wasting a call for every redundant racer. This only dedups within +/// ONE process; the Jira OAuth refresh-token race across processes is a separate, +/// already-solved problem (`meridian-oauth/src/store.rs::lock_provider`, a +/// cross-process file lock inside the refresh path itself). +fn sync_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + /// True once at least one PM task is cached. Rows only land in `pm_tasks` after a /// provider authenticated and fetched successfully, so a non-zero count is proof /// a tracker actually WORKS (not merely that keys are present — bad creds 401 and @@ -73,20 +96,74 @@ pub async fn run_pm_force_sync(meridian: &SqlitePool, config: &Config) -> Result Ok(()) } -/// Refreshes PM task caches from all configured providers. +/// Whether a sync is happening behind a real user action or on a clock. Only Jira +/// OAuth cares, and the difference is not cosmetic: see [`Trigger::Unattended`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Trigger { + /// A human just did something — opened the planner, connected a tracker, + /// pressed Sync now, edited a ticket, ran a CLI. The machine is provably awake + /// and in use, so spending the rotating refresh token is safe. + Attended, + /// A clock fired and nobody is necessarily there (the hourly worklog drafting + /// sweep). Jira OAuth must NOT refresh here: the refresh is a single-use + /// exchange whose lost response kills the grant permanently outside a + /// 10-minute window, and a laptop suspending mid-POST is exactly how that + /// happens with no one watching. An expired token means skip this pass; the + /// next attended request refreshes properly. Providers with static + /// credentials (GitHub/Linear/Trello/Azure) are unaffected — they have no + /// rotating credential to lose. + Unattended, +} + +/// Refreshes PM task caches from all configured providers whose cache has gone +/// stale, behind a real user action. See [`run_pm_sync_with`] for the shared body +/// and [`Trigger`] for why the distinction exists. #[tracing::instrument(skip_all)] pub async fn run_pm_sync(meridian: &SqlitePool, config: &Config) -> Result<()> { + run_pm_sync_with(meridian, config, Trigger::Attended).await +} + +/// [`run_pm_sync`] for clock-driven callers: refreshes the ticket cache only if it +/// can be done without minting a new Jira OAuth token, and quietly keeps the stale +/// cache otherwise. Used by the hourly worklog drafting sweep. +#[tracing::instrument(skip_all)] +pub async fn run_pm_sync_unattended(meridian: &SqlitePool, config: &Config) -> Result<()> { + run_pm_sync_with(meridian, config, Trigger::Unattended).await +} + +/// Refreshes PM task caches from all configured providers whose cache has gone stale +/// (per-provider `refresh_if_stale`) — a cheap no-op when nothing is stale. Called +/// from every on-demand trigger (the daily plan, worklog drafting, `meridian +/// pm-sync`), not a timer, so [`sync_lock`] serialises same-process callers that land +/// in the same instant rather than each racing the staleness check independently. +#[tracing::instrument(skip(meridian, config))] +pub async fn run_pm_sync_with( + meridian: &SqlitePool, + config: &Config, + trigger: Trigger, +) -> Result<()> { if config.pm_providers.is_empty() { tracing::warn!("no PM providers configured — pm_tasks will stay empty (set JIRA_BASE_URL/GITHUB_TOKEN/LINEAR_API_KEY/AZURE_DEVOPS_PAT)"); return Ok(()); } + + let _guard = match sync_lock().try_lock() { + Ok(g) => g, + Err(_) => { + tracing::debug!("run_pm_sync: another sync is already in flight — waiting for it"); + sync_lock().lock().await + } + }; + let provider_count = config.pm_providers.len(); tracing::debug!(provider_count, "syncing PM providers"); for provider in &config.pm_providers { let name = provider.provider_name(); let result = match provider { - PmProviderConfig::Jira(cfg) => providers::jira::refresh_if_stale(meridian, cfg).await, + PmProviderConfig::Jira(cfg) => { + providers::jira::refresh_if_stale(meridian, cfg, trigger).await + } PmProviderConfig::GitHub(cfg) => { providers::github::refresh_if_stale(meridian, cfg).await } @@ -220,4 +297,36 @@ mod tests { .unwrap(); assert_eq!(queued, 0, "the board hygiene digest producer was removed"); } + + /// `run_pm_sync` now fires from several independent on-demand callers instead of + /// one poll-loop tick, so two of them can land in the same instant. This proves + /// [`sync_lock`] actually serialises concurrent holders rather than being a no-op + /// — tested directly against the lock primitive since exercising the full + /// `run_pm_sync` path needs a live (or mocked) provider HTTP call this crate has + /// no test seam for. + #[tokio::test] + async fn sync_lock_serialises_concurrent_holders() { + let order = std::sync::Arc::new(tokio::sync::Mutex::new(Vec::<&str>::new())); + + let first_guard = sync_lock().lock().await; + let order_clone = order.clone(); + let second = tokio::spawn(async move { + let _g = sync_lock().lock().await; // must wait for `first_guard` to drop + order_clone.lock().await.push("second"); + }); + + // Give the spawned task a chance to actually block on the lock before we + // release it — otherwise a fast scheduler could run it after the drop below + // and the ordering assertion would prove nothing. + tokio::task::yield_now().await; + order.lock().await.push("first"); + drop(first_guard); + + second.await.unwrap(); + assert_eq!( + *order.lock().await, + vec!["first", "second"], + "the second acquirer must not proceed until the first guard drops" + ); + } } diff --git a/src/intelligence/oauth/jira.rs b/src/intelligence/oauth/jira.rs index 7af628c62..2aea4ede5 100644 --- a/src/intelligence/oauth/jira.rs +++ b/src/intelligence/oauth/jira.rs @@ -31,29 +31,47 @@ pub(crate) fn has_basic_auth(jira: &JiraConfig) -> bool { && !jira.api_token.trim().is_empty() } +/// The Basic-auth context from config. Shared by both resolvers so the two cannot +/// disagree about which config fields make up a request context — a field added to +/// [`JiraReqCtx::Basic`] has exactly one place to be filled in. +fn basic_ctx(jira: &JiraConfig) -> JiraReqCtx { + JiraReqCtx::Basic { + base_url: jira.base_url.clone(), + email: jira.email.clone(), + api_token: jira.api_token.clone(), + } +} + +/// The OAuth context from a token the caller has already obtained. Deliberately takes +/// the tokens rather than fetching them: that is the whole difference between the two +/// resolvers ([`resolve`] may mint a new pair, `resolve_unattended` must not), and +/// keeping the fetch out of here means neither can acquire one by accident. +fn oauth_ctx(t: meridian_oauth::store::OAuthTokens) -> JiraReqCtx { + JiraReqCtx::OAuth { + token: t.access_token, + cloud_id: t.cloud_id, + site_url: t.site_url, + } +} + /// Decide how to authenticate Jira requests: prefer the static API token when /// fully configured, otherwise fall back to a stored OAuth session. API token /// beats stored OAuth — a set JIRA_API_TOKEN always wins. /// This mirrors the industry standard (gh, Vercel CLI, Stripe CLI all follow /// env-var-first) and lets developers use a stable PAT in .env without being /// blocked by a stale OAuth session stored in ~/.meridian/oauth/jira.json. +/// +/// **May MINT a new refresh token, so only call this behind a real user action.** +/// See [`resolve_unattended`] for the clock-driven counterpart and why the +/// distinction is load-bearing. pub async fn resolve(jira: &JiraConfig) -> Result { if has_basic_auth(jira) { tracing::debug!(auth_method = "api_token", "resolving Jira auth"); - return Ok(JiraReqCtx::Basic { - base_url: jira.base_url.clone(), - email: jira.email.clone(), - api_token: jira.api_token.clone(), - }); + return Ok(basic_ctx(jira)); } if store::exists("jira") { tracing::debug!(auth_method = "oauth", "resolving Jira auth"); - let t = ensure_fresh().await?; - return Ok(JiraReqCtx::OAuth { - token: t.access_token, - cloud_id: t.cloud_id, - site_url: t.site_url, - }); + return Ok(oauth_ctx(ensure_fresh().await?)); } bail!( "no Jira auth available — run `meridian oauth-login jira`, \ @@ -61,6 +79,55 @@ pub async fn resolve(jira: &JiraConfig) -> Result { ) } +/// [`resolve`] for callers running on a CLOCK rather than behind a user action: +/// returns auth only if it can be had without spending the rotating refresh token. +/// `None` means "skip this pass", never an error. +/// +/// # Why a separate resolver +/// +/// Refreshing an Atlassian OAuth token is a single-use exchange: the old refresh +/// token dies the instant the new one is issued, so a lost response leaves the +/// grant recoverable only inside a 10-minute window. When that POST is fired by a +/// timer with nobody at the machine, a closing laptop lid destroys the grant +/// permanently — which is precisely how a production install lost Jira for five +/// days (refresh at 18:26:55, 28-minute suspend, retry instant on wake at +/// 18:55:29 but 18 minutes too late). +/// +/// So unattended code may USE a valid access token but must never MINT one. This +/// is the same discipline that makes Claude Code's MCP connections durable against +/// the identical protocol: it only ever refreshes on the tail of something a human +/// just asked for. +/// +/// API-token (basic) auth is returned unconditionally — a static token has no +/// expiry to race and no rotating credential to lose, so there is nothing to +/// protect it from. +/// +/// Deferring is close to free: a sweep that finds an expired token is by +/// definition running while nobody is using the machine, so there is little new +/// activity to match, and the next attended request refreshes properly. +/// +/// # Related +/// - [`resolve`] — the attended counterpart, which may refresh. +/// - [`meridian_oauth::jira::current_if_valid`] — the non-minting token read. +pub fn resolve_unattended(jira: &JiraConfig) -> Option { + if has_basic_auth(jira) { + tracing::debug!( + auth_method = "api_token", + "resolving Jira auth (unattended)" + ); + return Some(basic_ctx(jira)); + } + if !store::exists("jira") { + return None; + } + // `current_if_valid`, never `ensure_fresh` — this is the line that makes the + // function unattended-safe, and swapping it would silently reintroduce the + // timer-driven refresh that killed a production grant. + let t = meridian_oauth::jira::current_if_valid()?; + tracing::debug!(auth_method = "oauth", "resolving Jira auth (unattended)"); + Some(oauth_ctx(t)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/intelligence/providers/jira/refresh.rs b/src/intelligence/providers/jira/refresh.rs index dab541b0b..bcc4256e9 100644 --- a/src/intelligence/providers/jira/refresh.rs +++ b/src/intelligence/providers/jira/refresh.rs @@ -25,6 +25,7 @@ use sqlx::SqlitePool; use crate::config::JiraConfig; use crate::intelligence::providers::http::SyncFault; +use crate::intelligence::Trigger; use super::{ backfill_worklogged, discover_start_date_field, fetch, native_terminal, prune, upsert, @@ -36,7 +37,11 @@ use super::{ // --------------------------------------------------------------------------- #[tracing::instrument(skip(pool, jira))] -pub async fn refresh_if_stale(pool: &SqlitePool, jira: &JiraConfig) -> Result>> { +pub async fn refresh_if_stale( + pool: &SqlitePool, + jira: &JiraConfig, + trigger: Trigger, +) -> Result>> { let threshold = format!("-{SYNC_INTERVAL_MINS} minutes"); let (is_fresh,): (i64,) = sqlx::query_as( "SELECT EXISTS( @@ -70,66 +75,85 @@ pub async fn refresh_if_stale(pool: &SqlitePool, jira: &JiraConfig) -> Result ctx, - Err(e) => { - // A refresh that failed only because Atlassian was briefly - // unreachable (network blip, timeout, 429, 5xx) does NOT mean the - // token is dead — the stored refresh token is still valid and the - // next tick will almost certainly succeed. Raising a "Reconnect - // Jira / re-run oauth-login" sync error for that transient case is - // exactly what made this fault flap on and off at random. Keep the - // stale cache and stay quiet; the notice is reserved for a terminal - // auth failure the user actually has to act on. - // Deliberately NOT `record_sync_failure` like the fetch arm below: - // this path needs the auth-method-specific remedy override that the - // shared helper has no way to express (basic auth really does want - // the `.env` wording). The classification policy is otherwise - // identical, so a change to one should be mirrored here. - // - // `meridian_oauth::is_transient` only recognises a `TokenError` from - // the token endpoint and answers `false` for anything else, so a raw - // transport failure escaping the refresh would still land here as - // terminal. Falling back to `http::classify` closes that hole - // without ever making a genuinely dead grant look retryable. - let fault = if meridian_oauth::is_transient(&e) { - SyncFault::retry(&e) - } else { - crate::intelligence::providers::http::classify(&e) - }; - match fault { - SyncFault::Retry { detail } => { - tracing::warn!( - error = %detail, - "jira auth temporarily unavailable - keeping stale cache, will retry next sync" - ); - let _ = crate::intelligence::providers::note_transient_sync_failure( - pool, "jira", &detail, - ) - .await; - } - SyncFault::Report { detail } => { - tracing::warn!(error = %detail, "jira auth unavailable - keeping stale cache"); - let msg = format!("Jira auth failed: {detail}"); - // Basic-auth (JIRA_API_TOKEN/JIRA_BASE_URL) and OAuth are - // mutually exclusive here - has_basic_auth() mirrors - // resolve()'s own choice - so the remedy must match whichever - // path this failure came from, not always point at .env. - let remedy = if crate::intelligence::oauth::jira::has_basic_auth(jira) { - "Set JIRA_API_TOKEN and JIRA_BASE_URL in .env" - } else { - "Reconnect Jira in Settings - Integrations" - }; - let _ = crate::intelligence::providers::stamp_sync_error_with_remedy( - pool, - "jira", - &msg, - Some(remedy), - ) - .await; + // + // A clock-driven pass must never MINT a Jira OAuth token — spending the + // rotating refresh token unattended is what permanently kills a grant when the + // machine suspends mid-POST (see `Trigger::Unattended`). An expired token here + // means keep the stale cache and let the next attended request refresh; that + // is not a failure and must not be recorded as one, or a laptop that was shut + // for the night would raise a sync error every morning. + let ctx = if trigger == Trigger::Unattended { + match crate::intelligence::oauth::jira::resolve_unattended(jira) { + Some(ctx) => ctx, + None => { + tracing::debug!( + "jira token not usable without a refresh - deferring unattended sync" + ); + return Ok(None); + } + } + } else { + match crate::intelligence::oauth::jira::resolve(jira).await { + Ok(ctx) => ctx, + Err(e) => { + // A refresh that failed only because Atlassian was briefly + // unreachable (network blip, timeout, 429, 5xx) does NOT mean the + // token is dead — the stored refresh token is still valid and the + // next tick will almost certainly succeed. Raising a "Reconnect + // Jira / re-run oauth-login" sync error for that transient case is + // exactly what made this fault flap on and off at random. Keep the + // stale cache and stay quiet; the notice is reserved for a terminal + // auth failure the user actually has to act on. + // Deliberately NOT `record_sync_failure` like the fetch arm below: + // this path needs the auth-method-specific remedy override that the + // shared helper has no way to express (basic auth really does want + // the `.env` wording). The classification policy is otherwise + // identical, so a change to one should be mirrored here. + // + // `meridian_oauth::is_transient` only recognises a `TokenError` from + // the token endpoint and answers `false` for anything else, so a raw + // transport failure escaping the refresh would still land here as + // terminal. Falling back to `http::classify` closes that hole + // without ever making a genuinely dead grant look retryable. + let fault = if meridian_oauth::is_transient(&e) { + SyncFault::retry(&e) + } else { + crate::intelligence::providers::http::classify(&e) + }; + match fault { + SyncFault::Retry { detail } => { + tracing::warn!( + error = %detail, + "jira auth temporarily unavailable - keeping stale cache, will retry next sync" + ); + let _ = crate::intelligence::providers::note_transient_sync_failure( + pool, "jira", &detail, + ) + .await; + } + SyncFault::Report { detail } => { + tracing::warn!(error = %detail, "jira auth unavailable - keeping stale cache"); + let msg = format!("Jira auth failed: {detail}"); + // Basic-auth (JIRA_API_TOKEN/JIRA_BASE_URL) and OAuth are + // mutually exclusive here - has_basic_auth() mirrors + // resolve()'s own choice - so the remedy must match whichever + // path this failure came from, not always point at .env. + let remedy = if crate::intelligence::oauth::jira::has_basic_auth(jira) { + "Set JIRA_API_TOKEN and JIRA_BASE_URL in .env" + } else { + "Reconnect Jira in Settings - Integrations" + }; + let _ = crate::intelligence::providers::stamp_sync_error_with_remedy( + pool, + "jira", + &msg, + Some(remedy), + ) + .await; + } } + return Ok(None); } - return Ok(None); } }; let auth_method = if jira.api_token.is_empty() { @@ -225,10 +249,16 @@ pub async fn refresh_if_stale(pool: &SqlitePool, jira: &JiraConfig) -> Result Result>> { sqlx::query("DELETE FROM pm_sync_state WHERE provider = 'jira'") .execute(pool) .await .context("clearing jira sync state for force refresh")?; - refresh_if_stale(pool, jira).await + refresh_if_stale(pool, jira, Trigger::Attended).await } diff --git a/src/intelligence/sync_delegate.rs b/src/intelligence/sync_delegate.rs new file mode 100644 index 000000000..8475390f9 --- /dev/null +++ b/src/intelligence/sync_delegate.rs @@ -0,0 +1,320 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +//! Producer side of the `pm_sync_requests` outbox for **short-lived CLI processes**: +//! ask the daemon to sync, rather than syncing here. +//! +//! # Why this exists +//! +//! An Atlassian OAuth refresh token is single-use and rotating: the old token dies the +//! instant the new one is issued, so a lost response leaves the grant recoverable only +//! inside a 10-minute window and permanently dead after it. A credential like that has +//! exactly one safe writer, and [`crate::intelligence::sync_requests`] makes the daemon +//! that writer. +//! +//! The tray was converted to request-instead-of-sync at the same time, but six CLI +//! subcommands the tray *spawns* were missed and kept refreshing in their own process: +//! `plan-task-create` / `plan-task-edit` / `plan-task-done`, `ticket-update`, +//! `ticket-set-status`, and `worklog-generate`. Every one is user-triggered, so none of +//! them can recreate the timer-driven "refresh POST in flight when the lid closes" +//! failure - but each is a second process able to spend the token while the daemon's +//! watcher is servicing a request. The only thing serialising them was +//! `meridian-oauth`'s advisory file lock, whose 10 s timeout is shorter than the ~26 s +//! a refresh can take and which proceeds WITHOUT the lock on timeout. This closes that. +//! +//! # Why the in-process fallback stays +//! +//! A dev checkout, CI, or a support session with the daemon stopped still needs these +//! commands to refresh the board. With no daemon running there is no second writer, so +//! syncing here is safe by the same argument. [`crate::platform::daemon_already_running`] +//! is the same probe the single-instance guard uses, so the two cannot disagree about +//! who owns the data dir. +//! +//! # Who calls this +//! - `src/main.rs`'s `tasks-sync` / `pm-sync` / `ticket-update` / `ticket-set-status` / +//! `worklog-generate` arms. +//! - [`crate::plan_tasks`]'s `create` / `edit` / `done` post-write refresh. +//! +//! # Related +//! - [`meridian_core::pm_sync_requests`] - the request/claim/complete API. +//! - [`crate::intelligence::sync_requests`] - the daemon-side consumer that does the work. +//! - [`crate::intelligence::Trigger`] - why attendedness is tracked at all. + +use std::time::Duration; + +use meridian_core::pm_sync_requests::{self, SyncMode, ALL_PROVIDERS}; +use sqlx::SqlitePool; + +use crate::config::Config; + +/// How often to re-read the request row while waiting. Matched to the daemon watcher's +/// own 2 s tick rather than being tighter - a faster poll cannot see a result sooner. +const POLL_INTERVAL: Duration = Duration::from_millis(500); + +/// What happened to a delegated sync. +/// +/// `Pending` deliberately merges "queued, not waited for" and "waited, budget elapsed": +/// both mean the daemon owns the work and will finish it, which is the only thing a +/// caller can act on. Neither is a failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Delegation { + /// The sync finished successfully. `count` is the resulting `pm_tasks` total, and is + /// present only when the daemon reported it (the in-process path does not tally). + Synced { count: Option }, + /// The sync was attempted and failed, or the request could not be queued. The string + /// is already a flattened error chain, ready to print or log. + Failed { error: String }, + /// Queued for the daemon; no outcome is known yet. + Pending, +} + +/// Ask for a sync and wait up to `budget` for the outcome. +/// +/// Use this when the caller's next step *reads* `pm_tasks` and would behave differently +/// against a stale board - `plan-task-create` checking whether its new ticket has +/// mirrored, or `worklog-generate` matching sessions to tickets. Pick a budget that +/// fits inside the caller's own timeout: a `Pending` return means "carry on with what +/// is cached", never "fail". +pub async fn sync_and_wait( + pool: &SqlitePool, + config: &Config, + mode: SyncMode, + label: &str, + budget: Duration, +) -> Delegation { + delegate(pool, config, mode, label, Some(budget)).await +} + +/// The budget for a post-write refresh (`plan-task-done`, `plan-task-edit`, +/// `ticket-update`, `ticket-set-status`). +/// +/// These deliberately WAIT rather than firing and forgetting, because they were +/// synchronous before delegation and the frontend re-reads the board as soon as the CLI +/// exits - returning early would show the pre-write value for a second or two and read +/// as "my change didn't save". The old inline path already paid this latency (its own +/// HTTP call, up to ~26 s with a token refresh), so waiting is not a new cost. +pub const POST_WRITE_SYNC_BUDGET: Duration = Duration::from_secs(30); + +/// Ask for a post-write refresh and wait for it, using [`POST_WRITE_SYNC_BUDGET`]. +/// +/// A `Pending` return is not a failure: the tracker write already landed and the daemon +/// will still finish the mirror refresh. Callers log it and carry on. +pub async fn sync_after_write(pool: &SqlitePool, config: &Config, label: &str) -> Delegation { + delegate( + pool, + config, + SyncMode::Force, + label, + Some(POST_WRITE_SYNC_BUDGET), + ) + .await +} + +/// Shared body: pick the owner, then act. +/// +/// Only the branch lives here - both halves are separately testable, which the probe +/// makes necessary: [`crate::platform::daemon_already_running`] talks to the real +/// single-instance endpoint, so a test that called this function would pass or fail +/// depending on whether the developer happens to have a daemon running. +#[tracing::instrument(skip(pool, config), fields(mode = mode.as_str()))] +async fn delegate( + pool: &SqlitePool, + config: &Config, + mode: SyncMode, + label: &str, + wait: Option, +) -> Delegation { + if crate::platform::daemon_already_running().await { + request_and_wait(pool, mode, label, wait).await + } else { + sync_here(pool, config, mode, label).await + } +} + +/// The no-daemon fallback: do the sync in this process. +async fn sync_here(pool: &SqlitePool, config: &Config, mode: SyncMode, label: &str) -> Delegation { + tracing::debug!(label, "no daemon running - syncing in-process"); + let result = match mode { + SyncMode::Force => super::run_pm_force_sync(pool, config).await, + SyncMode::Gated => super::run_pm_sync(pool, config).await, + }; + match result { + Ok(()) => Delegation::Synced { count: None }, + Err(e) => Delegation::Failed { + error: crate::errors::chain(&e), + }, + } +} + +/// The delegated path: hand the work to the daemon, optionally waiting for its outcome. +/// `wait: None` returns [`Delegation::Pending`] the moment the request is written. +async fn request_and_wait( + pool: &SqlitePool, + mode: SyncMode, + label: &str, + wait: Option, +) -> Delegation { + if let Err(e) = pm_sync_requests::request(pool, ALL_PROVIDERS, mode, label).await { + return Delegation::Failed { + error: format!( + "could not queue the sync request: {}", + crate::errors::chain(&e) + ), + }; + } + tracing::debug!(label, "pm sync requested - the daemon owns tracker auth"); + + match wait { + Some(budget) => wait_for_outcome(pool, label, budget).await, + None => Delegation::Pending, + } +} + +/// Poll the request row until the daemon records an outcome, or `budget` elapses. +/// +/// The row is keyed on the provider (`'*'`), so a concurrent CLI's request can reset it +/// and this can end up reading a sibling's outcome. That is fine: both asked for the same +/// thing, and "some sync just completed" is exactly what the caller needs to know. It +/// cannot read a *stale* outcome, because `request` clears `completed_at`. +async fn wait_for_outcome(pool: &SqlitePool, label: &str, budget: Duration) -> Delegation { + let deadline = std::time::Instant::now() + budget; + loop { + if std::time::Instant::now() >= deadline { + tracing::debug!( + label, + budget_s = budget.as_secs(), + "daemon did not report a sync outcome in time - continuing" + ); + return Delegation::Pending; + } + tokio::time::sleep(POLL_INTERVAL).await; + match pm_sync_requests::outcome(pool, ALL_PROVIDERS).await { + Ok(Some(out)) => { + return match out.error { + Some(error) => Delegation::Failed { error }, + None => Delegation::Synced { + count: out.synced_count, + }, + }; + } + Ok(None) => continue, + Err(e) => { + return Delegation::Failed { + error: format!( + "could not read the sync outcome: {}", + crate::errors::chain(&e) + ), + }; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::SqliteConnectOptions; + use std::str::FromStr; + + async fn db() -> SqlitePool { + let opts = SqliteConnectOptions::from_str("sqlite::memory:") + .unwrap() + .create_if_missing(true); + let pool = SqlitePool::connect_with(opts).await.unwrap(); + sqlx::migrate!("src/migrations").run(&pool).await.unwrap(); + pool + } + + /// With no providers configured, `run_pm_*_sync` returns early - so the fallback + /// path must report success rather than queueing anything. If it ever starts writing + /// a request row, a dev checkout would silently stop refreshing: with no daemon + /// there is nothing to service it. + #[tokio::test] + async fn the_fallback_syncs_here_and_queues_nothing() { + let pool = db().await; + let cfg = Config::from_env(); + + let got = sync_here(&pool, &cfg, SyncMode::Force, "test").await; + + assert_eq!(got, Delegation::Synced { count: None }); + assert!( + pm_sync_requests::outcome(&pool, ALL_PROVIDERS) + .await + .unwrap() + .is_none(), + "the in-process path must not queue a request" + ); + } + + /// A queued request must be *claimable* by the daemon, carrying the mode and reason + /// the producer asked for. If the row were written in a shape `claim` cannot match, + /// every delegated sync would silently never happen. + #[tokio::test] + async fn a_queued_request_is_claimable_by_the_daemon() { + let pool = db().await; + + let got = request_and_wait(&pool, SyncMode::Force, "plan-task-done", None).await; + + assert_eq!(got, Delegation::Pending); + let claimed = pm_sync_requests::claim(&pool, ALL_PROVIDERS) + .await + .unwrap() + .expect("the daemon must be able to claim the queued request"); + assert_eq!(claimed.mode, SyncMode::Force); + assert_eq!(claimed.reason, "plan-task-done"); + } + + /// A budget that elapses with no daemon to service the row must read as `Pending`, + /// not `Failed`. `plan-task-create` and `worklog-generate` both continue on + /// `Pending` and would otherwise log a phantom failure on every slow sync. + #[tokio::test] + async fn an_elapsed_budget_is_pending_not_failed() { + let pool = db().await; + + let got = request_and_wait( + &pool, + SyncMode::Gated, + "worklog-generate", + Some(Duration::from_millis(600)), + ) + .await; + + assert_eq!(got, Delegation::Pending); + } + + /// A failure the daemon recorded must reach the caller verbatim, so `tasks-sync` + /// exits non-zero with the real reason rather than a generic timeout. + #[tokio::test] + async fn a_recorded_failure_is_reported_to_the_caller() { + let pool = db().await; + pm_sync_requests::request(&pool, ALL_PROVIDERS, SyncMode::Force, "tasks-sync") + .await + .unwrap(); + pm_sync_requests::claim(&pool, ALL_PROVIDERS).await.unwrap(); + pm_sync_requests::complete(&pool, ALL_PROVIDERS, None, Some("refresh_token is invalid")) + .await + .unwrap(); + + // Re-requesting clears the outcome, so the waiter must be the one to observe it: + // drive the wait directly against the already-completed row. + let got = wait_for_outcome(&pool, "tasks-sync", Duration::from_secs(5)).await; + + assert_eq!( + got, + Delegation::Failed { + error: "refresh_token is invalid".to_string() + } + ); + } + + /// `Pending` is not a failure, and callers branch on that. Pinned because the + /// obvious refactor - folding a timeout into `Failed` - would turn "the daemon is + /// still working" into a user-visible error on every slow sync. + #[test] + fn pending_is_distinct_from_failed() { + assert_ne!( + Delegation::Pending, + Delegation::Failed { + error: "x".to_string() + } + ); + } +} diff --git a/src/intelligence/sync_requests.rs b/src/intelligence/sync_requests.rs new file mode 100644 index 000000000..8e1838909 --- /dev/null +++ b/src/intelligence/sync_requests.rs @@ -0,0 +1,227 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +//! Single-owner PM sync: the daemon-side consumer of the `pm_sync_requests` outbox. +//! +//! # Why this task exists +//! +//! An Atlassian OAuth refresh token is single-use and rotating: the old token dies +//! the instant the new one is issued, so a lost response leaves the grant +//! recoverable only inside a 10-minute window and permanently dead after it. A +//! credential like that has exactly one safe writer. +//! +//! It used to have many. The tray refreshed in-process, the daemon refreshed on its +//! poll loop, and the tray spawned fresh `meridian pm-sync` / `tasks-sync` processes +//! that each refreshed too. The advisory file lock meant to serialise them could not: +//! its 10 s timeout is shorter than the ~26 s a refresh can take, and on timeout the +//! code proceeded WITHOUT the lock. Two processes could spend the same token, and +//! only Atlassian's grace window kept that from corrupting state. +//! +//! This watcher makes the daemon the sole consumer, so single-ownership is a property +//! of the architecture rather than of lock discipline. Every other would-be writer - +//! the tray, and the `tasks-sync` / `pm-sync` / `plan-task-*` / `ticket-update` / +//! `ticket-set-status` / `worklog-generate` CLIs - now writes a request row instead +//! (see [`crate::intelligence::sync_delegate`]). The only remaining in-process sync is +//! the fallback taken when no daemon is running at all, where there is no second +//! writer to race. +//! +//! # Why a separate task instead of the main poll loop +//! +//! The poll loop ticks on `POLL_INTERVAL_SECS` (60 s by default). Making a user who +//! pressed "Sync now" wait up to a minute for the sync to even *begin* would be a +//! visible regression against the old shell-out, which started immediately. So this +//! runs its own short cadence ([`WATCH_INTERVAL`]) - a single indexed read against a +//! local SQLite file, negligible next to the ETL work sharing the process. +//! +//! It is deliberately NOT a timer that syncs on its own: it only ever acts on a row +//! a producer wrote, so every refresh still traces back to a human action. That is +//! the property that stops a refresh POST being in flight when a laptop lid closes. +//! +//! # Who calls this +//! - [`run_watcher`] is spawned once by `src/main.rs` at daemon startup. +//! - Producers write rows via [`meridian_core::pm_sync_requests::request`]. +//! +//! # Related +//! - [`meridian_core::pm_sync_requests`] - the request/claim/complete API. +//! - [`crate::intelligence::run_pm_sync`] / [`crate::intelligence::run_pm_force_sync`] +//! - the work this dispatches to. +//! - [`crate::intelligence::Trigger`] - why attendedness is tracked at all. + +use std::time::Duration; + +use meridian_core::pm_sync_requests::{self, SyncMode, ALL_PROVIDERS}; +use sqlx::SqlitePool; +use tokio::sync::watch; + +use crate::config::Config; + +/// How often to look for a pending request. Short enough that "Sync now" feels +/// immediate, and cheap enough to be irrelevant: one indexed read of a single-row +/// table on a local file. +const WATCH_INTERVAL: Duration = Duration::from_secs(2); + +/// Drain PM sync requests for the life of the process. +/// +/// Releases claims stranded by a previous daemon exit once at startup (see +/// [`pm_sync_requests::reset_stale_claims`]), then loops. +/// +/// Never propagates an error: a failure to read the table, or a failing sync, must +/// not take down the daemon or stop future requests being serviced. Outcomes are +/// recorded on the row so a producer can surface them. +/// +/// Returns when `shutdown_rx` goes true. The shutdown check is on the SLEEP, not +/// mid-sync, so a sync already in flight is allowed to finish and record its +/// outcome rather than being cut off with the row left claimed. A hard kill during a +/// sync is still possible, and [`pm_sync_requests::reset_stale_claims`] cleans that +/// up on the next boot. +#[tracing::instrument(skip(pool, shutdown_rx))] +pub async fn run_watcher(pool: SqlitePool, mut shutdown_rx: watch::Receiver) { + if let Err(e) = pm_sync_requests::reset_stale_claims(&pool).await { + // Non-fatal: the table may not exist yet on a very old DB mid-migration. + // A stranded claim self-heals as soon as any producer writes a new request + // (which resets `claimed_at`), so this is a latency issue, not a dead end. + tracing::warn!( + error = %crate::errors::chain(&e), + "could not release stale PM sync claims - a pending request may wait for the next producer" + ); + } + + loop { + tokio::select! { + _ = tokio::time::sleep(WATCH_INTERVAL) => service_once(&pool).await, + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + tracing::debug!("PM sync request watcher stopping"); + return; + } + } + } + } +} + +/// Claim and service at most one pending request. Split from the loop so the +/// decision logic is reachable without spawning a task. +async fn service_once(pool: &SqlitePool) { + let req = match pm_sync_requests::claim(pool, ALL_PROVIDERS).await { + Ok(Some(req)) => req, + Ok(None) => return, + Err(e) => { + // Logged at debug, not warn: on a fresh install this fires every 2 s + // until migration 082 has run, and a warn-level line every 2 s would + // bury real problems (and, being WARN+, would egress to central + // observability on every packaged install). + tracing::debug!( + error = %crate::errors::chain(&e), + "could not read PM sync requests" + ); + return; + } + }; + + // Config is read here rather than captured at spawn time so a settings change + // (a newly connected tracker, an edited JQL) is picked up without a restart. + // Cheap because this only runs when there is actually work. + let cfg = Config::from_env(); + + tracing::info!( + mode = req.mode.as_str(), + reason = %req.reason, + "servicing PM sync request" + ); + + // Both arms are ATTENDED: a row exists only because a producer wrote it in + // response to a user action, which is precisely the condition that makes + // spending the rotating refresh token safe. There is deliberately no unattended + // path through this watcher - the clock-driven worklog sweep calls + // `run_pm_sync_unattended` directly instead of requesting. + let result = match req.mode { + SyncMode::Force => crate::intelligence::run_pm_force_sync(pool, &cfg).await, + SyncMode::Gated => crate::intelligence::run_pm_sync(pool, &cfg).await, + }; + + let (count, error) = match result { + Ok(()) => { + // The count is read back from `pm_tasks` rather than threaded out of the + // sync: `run_pm_sync` fans out over every provider and a per-provider + // tally would have to pick one to report. The board total is what "Sync + // now" actually wants to show. + let n = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM pm_tasks") + .fetch_one(pool) + .await + .ok(); + tracing::info!(task_count = ?n, "PM sync request completed"); + (n, None) + } + Err(e) => { + let detail = crate::errors::chain(&e); + tracing::warn!(error = %detail, "PM sync request failed"); + (None, Some(detail)) + } + }; + + if let Err(e) = pm_sync_requests::complete(pool, &req.provider, count, error.as_deref()).await { + tracing::warn!( + error = %crate::errors::chain(&e), + "could not record the PM sync outcome - the producer will keep waiting" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::SqliteConnectOptions; + use std::str::FromStr; + + async fn db() -> SqlitePool { + let opts = SqliteConnectOptions::from_str("sqlite::memory:") + .unwrap() + .create_if_missing(true); + let pool = SqlitePool::connect_with(opts).await.unwrap(); + sqlx::migrate!("src/migrations").run(&pool).await.unwrap(); + pool + } + + /// An empty table must be a silent no-op. This runs every 2 s forever, so any + /// noise or error here would be a permanent log flood. + #[tokio::test] + async fn service_once_is_quiet_with_no_requests() { + let pool = db().await; + service_once(&pool).await; + assert!(pm_sync_requests::outcome(&pool, ALL_PROVIDERS) + .await + .unwrap() + .is_none()); + } + + /// With no PM providers configured, `run_pm_sync` returns `Ok(())` early, so the + /// request must still be marked complete rather than left pending forever - a + /// producer polling for the outcome would otherwise hang. + #[tokio::test] + async fn a_request_is_completed_even_with_no_providers_configured() { + let pool = db().await; + pm_sync_requests::request(&pool, ALL_PROVIDERS, SyncMode::Gated, "test") + .await + .unwrap(); + + service_once(&pool).await; + + let out = pm_sync_requests::outcome(&pool, ALL_PROVIDERS) + .await + .unwrap() + .expect("the request must be completed, not left pending"); + assert!(out.error.is_none(), "unexpected error: {:?}", out.error); + } + + /// The migration must actually create the table the watcher depends on. + #[tokio::test] + async fn migration_creates_the_requests_table() { + let pool = db().await; + let exists: i64 = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = 'pm_sync_requests')", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(exists, 1); + } +} diff --git a/src/main.rs b/src/main.rs index cada9f44d..5867f0542 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,8 +8,9 @@ use anyhow::{Context, Result}; use meridian::config::Config; use meridian::db::meridian::{cleanup_incomplete_runs, setup_db}; use meridian::etl::run_etl; -use meridian::intelligence::{run_pm_force_sync, run_pm_sync}; +use meridian::intelligence::sync_delegate::Delegation; use meridian::observability; +use meridian_core::pm_sync_requests::SyncMode; use tokio::sync::Notify; use tracing::Instrument; @@ -350,23 +351,45 @@ async fn main() -> Result<()> { return Ok(()); } - // `meridian tasks-sync` — force an immediate sync of all configured PM - // providers (Jira, Linear, GitHub), bypassing the 5-minute staleness gate. - // Exits 0 on success, non-zero if the DB cannot be opened. - if std::env::args().nth(1).as_deref() == Some("tasks-sync") { + // `meridian tasks-sync` (force) / `meridian pm-sync` (gated) — the two on-demand + // CLI sync entry points. There is no background poller anymore; see + // `src/intelligence/mod.rs`'s doc comments for the full trigger list. + // + // `tasks-sync` bypasses the per-provider staleness gate (the user asked for fresh + // data now); `pm-sync` honours it, so it is a cheap no-op on an already-fresh + // board. Both DELEGATE to a running daemon rather than syncing here — see + // `cli_sync` for why the rotating credential has exactly one safe writer. + // + // One arm, because the two differ only in mode and label. They were separate + // copies of the same fourteen lines, which is how `pm-sync` ended up documented as + // the command the tray used "on opening the dashboard" — a trigger that no longer + // exists. + // + // Exit 1 if the DB cannot be opened, or if the sync itself failed. Reporting the + // sync failure in the exit code matters: with no daemon running the tray's "Sync + // now" shells out to `tasks-sync` and reads a non-zero exit as the failure, so + // exiting 0 here would show the user a successful sync that did not happen. A + // timeout is NOT a failure (`cli_sync` returns true) — the daemon is still working. + let cli_sync_mode = match std::env::args().nth(1).as_deref() { + Some("tasks-sync") => Some((SyncMode::Force, "tasks-sync")), + Some("pm-sync") => Some((SyncMode::Gated, "pm-sync")), + _ => None, + }; + if let Some((mode, label)) = cli_sync_mode { let cfg = Config::from_env(); match setup_db(&cfg.meridian_db_uri()).await { Ok(pool) => { - if let Err(e) = run_pm_force_sync(&pool, &cfg).await { - eprintln!("tasks-sync: {e}"); - } + let ok = cli_sync(&pool, &cfg, mode, label).await; pool.close().await; + if !ok { + std::process::exit(1); + } } Err(e) => { // `{e:#}` prints the full anyhow source chain (e.g. the sqlx // "migration N was previously applied / missing" cause) instead // of just the top-level "failed to run migrations" context. - eprintln!("tasks-sync: open db: {e:#}"); + eprintln!("{label}: open db: {e:#}"); std::process::exit(1); } } @@ -404,7 +427,7 @@ async fn main() -> Result<()> { meridian::intelligence::ticket_update::ApplyStatus::Applied ) { if let Ok(pool) = setup_db(&cfg.meridian_db_uri()).await { - let _ = run_pm_force_sync(&pool, &cfg).await; + cli_sync_after_write(&pool, &cfg, "ticket-update").await; pool.close().await; } } @@ -510,7 +533,7 @@ async fn main() -> Result<()> { meridian::intelligence::ticket_update::ApplyStatus::Applied ) { if let Ok(pool) = setup_db(&cfg.meridian_db_uri()).await { - let _ = run_pm_force_sync(&pool, &cfg).await; + cli_sync_after_write(&pool, &cfg, "ticket-set-status").await; pool.close().await; } } @@ -694,6 +717,32 @@ async fn main() -> Result<()> { let obs_guard = observability::init("meridian-rust").ok(); match setup_db(&cfg.meridian_db_uri()).await { Ok(pool) => { + // Matching needs current ticket state — a stale board could silently + // bind this draft to a closed/renamed ticket. Best-effort: a sync + // failure must not block drafting against whatever's cached. + // + // Delegated to the daemon, the sole owner of the rotating Jira OAuth + // token (see `intelligence::sync_delegate`). The wait budget is short + // because an LLM call follows inside the tray's 150 s timeout for this + // command: better to draft against a slightly stale board than to blow + // the timeout and show the user nothing at all. + match meridian::intelligence::sync_delegate::sync_and_wait( + &pool, + &cfg, + SyncMode::Gated, + "worklog-generate", + std::time::Duration::from_secs(30), + ) + .await + { + Delegation::Synced { .. } => {} + Delegation::Failed { error } => { + tracing::warn!(%error, "worklog-generate: pm sync failed — matching against cached tasks"); + } + Delegation::Pending => { + tracing::warn!("worklog-generate: pm sync still running — matching against cached tasks"); + } + } match meridian::pm_worklog::generate(&pool, &cfg, &day, &task_id).await { Ok(mut draft) => { // A matched draft carries a target_key we can link even @@ -1256,9 +1305,7 @@ async fn main() -> Result<()> { } // 7c. Run ETL once immediately before entering the loop. - // Re-read config so that any settings.json present at startup takes effect. { - let cfg = Config::from_env(); let startup_tick = tracing::info_span!("startup_tick"); *etl_tick_span.lock().unwrap_or_else(|e| e.into_inner()) = Some(startup_tick.clone()); let _guard = startup_tick.enter(); @@ -1282,12 +1329,10 @@ async fn main() -> Result<()> { tracing::warn!(error = %meridian::errors::chain(&e), "capture retention sweep failed"); } } - if let Err(e) = run_pm_sync(&meridian, &cfg).await { - tracing::error!( - error = %meridian::errors::chain(&e), - "intelligence run failed" - ); - } + // No PM sync here anymore — there's no background poller. Syncing is + // on-demand now (the daily plan, the match-to-ticket picker, connecting a + // tracker, a board write, worklog drafting, `meridian tasks-sync`/`pm-sync`); + // see `src/intelligence/mod.rs`'s doc comments for the full set of triggers. } let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); @@ -1372,6 +1417,32 @@ async fn main() -> Result<()> { }); } + // 7h. PM sync request watcher — the daemon is the SOLE holder of the rotating + // Jira OAuth refresh token, so every other process (the tray, the CLIs) asks + // for a sync by writing a `pm_sync_requests` row instead of refreshing + // itself. This drains those requests. + // + // Why single-ownership matters: the refresh is a single-use exchange whose + // lost response kills the grant outside a 10-minute window. Serialising N + // processes with an advisory file lock did not work — a 10 s lock timeout + // guarding a ~26 s operation, and on timeout the code proceeded WITHOUT the + // lock — so two processes could spend the same token and only Atlassian's + // grace window prevented corruption. + // + // Deliberately its own task on a short cadence rather than folded into the + // 60 s poll loop below: a user pressing "Sync now" must not wait up to a + // minute for the sync to begin. It never syncs on its own initiative — only + // ever on a row a producer wrote, so every refresh still traces to a human + // action, which is what keeps a refresh POST from being in flight when a + // laptop lid closes. + { + let pool_sync = meridian.clone(); + let rx_sync = shutdown_rx.clone(); + tokio::spawn(async move { + meridian::intelligence::sync_requests::run_watcher(pool_sync, rx_sync).await; + }); + } + // 8b. Poll loop — ETL, PM sync, and FM categorization on the configured interval. // Track the last-applied log level so we can detect changes and hot-reload // the EnvFilter without restarting the daemon. @@ -1506,17 +1577,16 @@ async fn main() -> Result<()> { tracing::debug!(error = %meridian::errors::chain(&e), "notification response consume skipped"); } - // Refresh the PM task cache (pm_tasks) every tick — interval-gated - // per provider (~5 min), so this is a cheap no-op most ticks. The - // legacy drafting driver that used to trigger this before every - // pass was retired when the worklog pipeline moved to the - // clock-aligned Python trigger, which never calls this itself — - // leaving pm_tasks (and hence a ticket's title on the timeline) - // stuck at whatever it was at the last daemon restart. This is - // the only thing that keeps it live during normal operation. - if let Err(e) = run_pm_sync(&meridian, &cfg).await { - tracing::warn!(error = %meridian::errors::chain(&e), "pm_tasks refresh failed — using cached tasks"); - } + // No PM sync on this tick anymore — there's no background poller. + // `pm_tasks` is refreshed on-demand instead: the daily plan and the + // match-to-ticket picker (the two screens that decide something from + // the whole board), connecting a tracker, any board write, the worklog + // drafting sweep (`pm_worklog::auto_generate`), and the manual + // `meridian tasks-sync`/`pm-sync` CLI paths. See + // `src/intelligence/mod.rs`'s doc comments for the full trigger list + // and the reasoning (fewer standing background refreshes also shrinks + // how often a Jira OAuth token refresh can straddle a laptop + // sleep/wake). } } } @@ -1549,6 +1619,76 @@ async fn main() -> Result<()> { Ok(()) } +/// Body of the `tasks-sync` / `pm-sync` CLIs: ask the daemon and report what it did. +/// +/// The delegation itself (and why a CLI must not spend the rotating Jira OAuth token +/// itself) lives in [`meridian::intelligence::sync_delegate`]; this only maps the +/// outcome onto stdout/stderr and an exit code, so the user-facing wording stays in +/// one place next to the other CLI output. +async fn cli_sync( + pool: &meridian::db::SqlitePool, + cfg: &Config, + mode: SyncMode, + label: &str, +) -> bool { + // A generous budget: a cold sync across five providers with a token refresh can + // legitimately take a while, and a CLI that gives up early looks like a failure + // when the sync is still going. Syncing IS the point of this command, so unlike + // the post-write callers it waits. + match meridian::intelligence::sync_delegate::sync_and_wait( + pool, + cfg, + mode, + label, + std::time::Duration::from_secs(120), + ) + .await + { + Delegation::Synced { count: Some(n) } => { + println!("{label}: synced {n} task(s)"); + true + } + Delegation::Synced { count: None } => { + println!("{label}: synced"); + true + } + Delegation::Failed { error } => { + eprintln!("{label}: {error}"); + false + } + // NOT an error exit: the request is queued and the daemon will service it. + // Exiting non-zero here would fail scripts over a slow sync that ultimately + // succeeds. + Delegation::Pending => { + println!("{label}: still running - it will finish in the background"); + true + } + } +} + +/// Refresh the board after a CLI write applied to the tracker (`ticket-update`, +/// `ticket-set-status`). +/// +/// Waits for the outcome (see `sync_delegate::POST_WRITE_SYNC_BUDGET`): the frontend +/// re-reads the board as soon as this process exits, so returning before the mirror +/// caught up would briefly show the pre-write value and read as a lost edit. Failures +/// and timeouts are logged, never printed - the tracker write already succeeded, so a +/// sync hiccup must not make the command look like it failed. +async fn cli_sync_after_write(pool: &meridian::db::SqlitePool, cfg: &Config, label: &str) { + match meridian::intelligence::sync_delegate::sync_after_write(pool, cfg, label).await { + Delegation::Synced { .. } => {} + Delegation::Failed { error } => { + tracing::warn!(label, %error, "post-write pm sync failed - the tracker write still landed"); + } + Delegation::Pending => { + tracing::warn!( + label, + "post-write pm sync still running - the tracker write still landed" + ); + } + } +} + /// Runs one ETL pass and maps the outcome onto the notice bus. /// /// Returns `true` when the failure was database corruption, which the caller diff --git a/src/migrations/082_pm_sync_requests.sql b/src/migrations/082_pm_sync_requests.sql new file mode 100644 index 000000000..9a49eaaeb --- /dev/null +++ b/src/migrations/082_pm_sync_requests.sql @@ -0,0 +1,68 @@ +-- ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +-- +-- Single-owner PM sync: the request side of the outbox. +-- +-- WHY THIS EXISTS +-- An Atlassian OAuth refresh token is single-use and rotating: the old token dies +-- the instant the new one is issued, so a lost response leaves the grant +-- recoverable only inside a 10-minute window and unrecoverable after it. That +-- makes the token a resource with exactly one safe writer. +-- +-- It had several. The tray refreshed in-process, the daemon refreshed on its poll +-- loop, and the tray spawned `meridian pm-sync` / `tasks-sync` as fresh processes +-- that each refreshed too. Mutual exclusion was left to an advisory file lock that +-- could not actually deliver it: its 10 s timeout is shorter than the ~26 s a +-- refresh can take (3 attempts x 8 s + backoff), and on failure the code proceeded +-- WITHOUT the lock rather than backing off. Two processes could therefore spend the +-- same token, and the only thing preventing corruption was Atlassian's grace window +-- handing the loser the current pair. Correctness by vendor accident. +-- +-- So sync becomes a REQUEST rather than an action. Producers (tray window opens, +-- tracker connect, "Sync now", the CLI) write a row here; the daemon is the sole +-- consumer and the sole holder of the credential. +-- +-- COALESCING, NOT A QUEUE +-- `provider` is the PRIMARY KEY so repeated requests collapse into one pending row +-- (`ON CONFLICT DO UPDATE`). Opening the dashboard ten times must not queue ten +-- syncs - it must mean "a sync is wanted", once. `'*'` is the all-providers request +-- that every current producer writes; per-provider rows are reserved for a future +-- caller that needs to refresh just one board. +-- +-- `mode` escalates and never de-escalates while a row is pending: a 'force' request +-- landing on a pending 'gated' one upgrades it, because a user who just connected a +-- tracker or pressed "Sync now" must not have their explicit request downgraded by a +-- passing window focus. The reverse is silently ignored by the producer's UPSERT. +-- +-- COMPLETION IS REPORTED IN PLACE, NOT DELETED +-- The daemon stamps `completed_at` plus `error` / `synced_count` rather than removing +-- the row, so "Sync now" can show a real outcome without the tray needing to hold the +-- credential or shell out. A serviced row is retained until the next request replaces +-- it, which also gives `meridian health` a cheap, content-free view of the last sync +-- attempt. + +CREATE TABLE IF NOT EXISTS pm_sync_requests ( + -- '*' = all configured providers. A specific provider name scopes the request. + provider TEXT NOT NULL PRIMARY KEY, + -- 'gated' - honour the per-provider staleness window (the cheap common case). + -- 'force' - bypass it; the user explicitly asked (connect, "Sync now", CLI). + mode TEXT NOT NULL DEFAULT 'gated', + -- Free-text producer tag for tracing only (e.g. 'dashboard_open', 'token_connected'). + -- Never a user-content value: this is read back into logs. + reason TEXT NOT NULL DEFAULT '', + requested_at TEXT NOT NULL, + -- Set when the daemon starts servicing, so a request in flight is distinguishable + -- from one still waiting. Cleared on the next request. + claimed_at TEXT, + -- Set when the daemon finishes, success or failure. NULL while pending/in-flight. + completed_at TEXT, + -- NULL on success. The failure detail otherwise, for the tray to surface. + error TEXT, + -- Tasks refreshed on the last completed pass, for "Sync now" feedback. + synced_count INTEGER +); + +-- No secondary index on purpose. Every hot query (`claim`, `complete`, `outcome`, +-- `request`) filters on `provider`, which is the PRIMARY KEY, so an index on +-- (completed_at, claimed_at) would never be chosen. The only query that does not +-- name a provider is `reset_stale_claims`, which runs once per daemon boot over a +-- table holding one row per provider - a scan there is free. diff --git a/src/plan_tasks/create.rs b/src/plan_tasks/create.rs index 16a62e1b7..2b8867e27 100644 --- a/src/plan_tasks/create.rs +++ b/src/plan_tasks/create.rs @@ -30,13 +30,22 @@ //! - [`meridian_core::plan`] — `apply_plan_action("add")`, which puts the key in today. use anyhow::{Context, Result}; +use meridian_core::pm_sync_requests::SyncMode; use meridian_core::task_create::{self, NewTask}; use serde::Serialize; use sqlx::SqlitePool; +use std::time::Duration; use tracing::field::Empty; use tracing::Instrument; use crate::config::Config; +use crate::intelligence::sync_delegate::Delegation; + +/// How long to wait for the post-create sync before falling through to the shadow row. +/// Must stay comfortably inside the tray's 90 s `WRITE_TIMEOUT` for `plan-task-create`, +/// since a blown tray timeout looks to the user like a failed create even though the +/// ticket was filed. +const POST_CREATE_SYNC_BUDGET: Duration = Duration::from_secs(60); /// Where a new task should live. #[derive(Debug, Clone, PartialEq, Eq)] @@ -177,9 +186,30 @@ async fn create_on_tracker( tracing::Span::current().record("synced", true); // Pull the authoritative row in immediately rather than waiting out the 5-minute - // sync gate. Best-effort: a failed sync is not a failed create. - if let Err(e) = crate::intelligence::run_pm_force_sync(pool, config).await { - tracing::warn!(error = %e, "plan_task: post-create sync failed - will shadow the row"); + // sync gate. Best-effort: a failed sync is not a failed create - it just means the + // `task_exists` check below falls through to the shadow row. + // + // Delegated to the daemon (see `intelligence::sync_delegate`) because this is a + // short-lived CLI process and the rotating Jira OAuth token has exactly one safe + // writer. Unlike the edit/done paths this WAITS for the outcome: the very next line + // reads `pm_tasks`, so returning before the sync landed would shadow every created + // ticket. The budget sits inside the tray's 90 s `WRITE_TIMEOUT` for this command. + match crate::intelligence::sync_delegate::sync_and_wait( + pool, + config, + SyncMode::Force, + "plan-task-create", + POST_CREATE_SYNC_BUDGET, + ) + .await + { + Delegation::Synced { .. } => {} + Delegation::Failed { error } => { + tracing::warn!(%error, "plan_task: post-create sync failed - will shadow the row"); + } + Delegation::Pending => { + tracing::warn!("plan_task: post-create sync still running - will shadow the row"); + } } if task_create::task_exists(pool, &key).await? { diff --git a/src/plan_tasks/done.rs b/src/plan_tasks/done.rs index 53dc4496a..e0a5643d4 100644 --- a/src/plan_tasks/done.rs +++ b/src/plan_tasks/done.rs @@ -29,6 +29,7 @@ use tracing::field::Empty; use tracing::Instrument; use crate::config::Config; +use crate::intelligence::sync_delegate::Delegation; use crate::intelligence::ticket_update::{self, ApplyStatus}; use crate::plan_tasks::edit::EditResult; @@ -117,8 +118,20 @@ async fn set_done_on_tracker( // Best-effort — the tracker has already accepted the transition, so a sync hiccup // must not read as a failed toggle. - if let Err(e) = crate::intelligence::run_pm_force_sync(pool, config).await { - tracing::warn!(error = %e, "plan_task: post-toggle sync failed - the change still landed"); + // + // Delegated to the daemon (`intelligence::sync_delegate`), which is the only process + // that may spend the rotating Jira OAuth token. Nothing below reads `pm_tasks`, so + // this does not wait for the outcome - the request row is written and the daemon + // picks it up within ~2 s, well before the user's next board read. + match crate::intelligence::sync_delegate::sync_after_write(pool, config, "plan-task-done").await + { + Delegation::Synced { .. } => {} + Delegation::Failed { error } => { + tracing::warn!(%error, "plan_task: post-toggle sync failed - the change still landed"); + } + Delegation::Pending => { + tracing::warn!("plan_task: post-toggle sync still running - the change still landed"); + } } tracing::Span::current().record("status", "applied"); tracing::info!(field, "plan_task: tracker task status set"); diff --git a/src/plan_tasks/edit.rs b/src/plan_tasks/edit.rs index 7b244755e..df4523497 100644 --- a/src/plan_tasks/edit.rs +++ b/src/plan_tasks/edit.rs @@ -32,6 +32,7 @@ use tracing::field::Empty; use tracing::Instrument; use crate::config::Config; +use crate::intelligence::sync_delegate::Delegation; use crate::intelligence::ticket_update::{self, ApplyStatus}; /// The outcome of an edit, serialized to the CLI's one JSON line. @@ -157,8 +158,20 @@ async fn edit_on_tracker( // Reflect the applied write back into our mirror (the `ticket-update` CLI // does exactly this after an Applied write). Best-effort — the tracker has // already accepted it, so a sync hiccup must not read as a failed edit. - if let Err(e) = crate::intelligence::run_pm_force_sync(pool, config).await { - tracing::warn!(error = %e, "plan_task: post-edit sync failed - the edit still landed"); + // + // Delegated to the daemon (`intelligence::sync_delegate`), the only process that + // may spend the rotating Jira OAuth token. Nothing below reads `pm_tasks`, so the + // outcome is not awaited. + match crate::intelligence::sync_delegate::sync_after_write(pool, config, "plan-task-edit") + .await + { + Delegation::Synced { .. } => {} + Delegation::Failed { error } => { + tracing::warn!(%error, "plan_task: post-edit sync failed - the edit still landed"); + } + Delegation::Pending => { + tracing::warn!("plan_task: post-edit sync still running - the edit still landed"); + } } } tracing::Span::current().record("status", "applied"); diff --git a/src/pm_worklog/auto_generate/mod.rs b/src/pm_worklog/auto_generate/mod.rs new file mode 100644 index 000000000..a72ebe467 --- /dev/null +++ b/src/pm_worklog/auto_generate/mod.rs @@ -0,0 +1,216 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +//! Auto-generate worklog DRAFTS, once a day, at a user-chosen local clock time — +//! the opt-in behind `settings.worklog_auto_generate_time`. +//! +//! This runs the exact same action as the day-task detail panel's "Generate +//! worklog" button (`pm_worklog::generate`), automatically, for any task past the +//! fixed [`sweep::QUALIFYING_MINUTES`] threshold that doesn't have a draft yet. It +//! NEVER approves or posts — approving is always a deliberate human click +//! (`pm_worklog::approve`, the panel's "Approve & post"). Auto-generate only saves +//! the user the click that starts the draft; the tracker is never touched without +//! them. +//! +//! Off by default: the field is `None` until the user picks a time from the +//! Timeline nudge or Settings → Worklogs, so nothing here ever runs unattended for +//! someone who hasn't said yes. +//! +//! # A TRACKER IS NOT REQUIRED — and used to be, wrongly +//! Both entry points opened with `if config.pm_providers.is_empty() { return }`, +//! on the premise that "there is nothing to match against, so the sweep would only +//! fail every call". That premise is false, and stopped being true the moment +//! personal tasks became first-class: [`crate::pm_worklog::generate`] matches a +//! PERSONAL (`local`) day-task exactly like a real ticket and, on approve, posts to +//! that task's own row (`post_to_local_task`) — no tracker anywhere in the path. +//! Only the PROPOSE branch needs a configured provider, and that branch failing is +//! already a per-task `warn!` the loop walks past. +//! +//! So the gate silently disabled the whole feature for every tracker-less user — +//! the cohort Meridian now leads with — and Settings mirrored it with a dead-end +//! card offering nothing. Removed at both ends. Keep it that way: the correct floor +//! is "does this task have qualifying minutes and no draft yet", which is what the +//! sweep already checks, not "does this user have Jira". +//! +//! # Once a day, from the chosen time onward — not continuously, and self-healing +//! [`maybe_auto_generate`] is called every clock-aligned wake (HH:03, same as the +//! rest of the worklog pipeline), but only actually scans once the current local +//! hour has REACHED the chosen time's hour — so a task crossing the threshold at +//! 2pm with a 6pm setting waits for 6pm, not the next tick after 2pm. +//! +//! Deliberately `>=`, not `==`: if the Mac is asleep, off, or the app isn't +//! running at exactly 9pm, there is no tick at 9pm to miss — the daemon simply +//! catches up on its next wake that same day (10pm, or whenever the machine comes +//! back), because every tick from the chosen hour through local midnight re-runs +//! the same scan. This is safe to repeat because already-drafted tasks are +//! skipped (see below) — a caught-up run only ever drafts what genuinely wasn't +//! drafted yet, it never redoes work. The one thing this does NOT do is reach +//! back across local midnight: if the whole rest of the day was slept through, +//! that day's catch-up is skipped, matching this pipeline's standing "no +//! backfill past today" policy — the manual "Generate worklog" button in the +//! panel always still works for any past day. +//! +//! # Fires once per task, not on every qualifying tick +//! Once a task has ANY draft (auto- or manually generated), this leaves it alone +//! for good — it does not keep re-drafting an already-drafted task on a later +//! tick, whether that's later the same evening (the catch-up case above) or a +//! future day. If the user keeps working the task past the auto-draft, getting +//! an up-to-date version is a manual "Regenerate" click in the panel (which +//! shows when the draft was last generated, via `DayTaskWorklogDraft:: +//! updated_at`) — auto-generate hands off to that rather than looping forever. +//! +//! # One task at a time +//! Tasks are processed in a plain sequential loop, not fanned out — each `generate` +//! call is a real LLM request, and running them one after another (rather than +//! concurrently) keeps this predictable and easy to reason about from the trace. +//! +//! # Board freshness +//! [`sweep::draft_qualifying_tasks`] syncs the PM board (gated, ~5 min per provider) +//! before matching — there is no background poller keeping `pm_tasks` fresh +//! anymore, so this sweep is one of the few places that has to ask for it itself. +//! +//! # Observability +//! The whole run is one `worklog.auto_generate.run` span (see +//! [`maybe_auto_generate`]) carrying the gate outcome and per-run counts as +//! attributes — `gate` names exactly why a run did or didn't scan (`disabled`, +//! `before_chosen_hour`, `malformed_time`, or `ran`), so "why didn't this fire +//! tonight" is answerable from the trace alone, not by reading code. Each task +//! that actually gets drafted (or fails to) gets its own `info!`/`warn!` — real, +//! individually actionable events — but a task skipped for already having a +//! draft or not yet qualifying is silent (folded into the summary counts only), +//! so a quiet evening with nothing to do doesn't spam the log with one line per +//! task. +//! +//! # Who calls this +//! [`crate::worklog_pipeline::run_loop`], every clock-aligned wake (HH:03), right +//! after the hourly activity-report pass. +//! +//! # Related +//! - [`crate::pm_worklog::generate`] — the exact function the manual button calls; +//! this module never calls [`crate::pm_worklog::approve`]. +//! - [`meridian_core::day_tasks::get_day_tasks`] — the read model this scans; +//! `DayTask::minutes` is the deterministic measured total. +//! - [`meridian_core::day_task_worklogs::get_day_task_worklog`] — the "has this +//! task already been drafted" check that makes auto-generate fire exactly once +//! per task. +//! - [`sweep`] — the mechanical per-task loop, split out when this file passed the +//! 500-line cap. + +mod sweep; + +use chrono::{Local, Timelike}; +use sqlx::SqlitePool; +use tracing::field::Empty; +use tracing::Instrument; + +use crate::config::Config; +use sweep::draft_qualifying_tasks; + +/// Parse "HH:MM" into `(hour, minute)`. `None` for anything malformed — the +/// settings write path (`update_settings`) already rejects a bad value, so this +/// only has to be defensive against a hand-edited file. +fn parse_hh_mm(s: &str) -> Option<(u32, u32)> { + let (h, m) = s.split_once(':')?; + let (h, m) = (h.parse::().ok()?, m.parse::().ok()?); + (h < 24 && m < 60).then_some((h, m)) +} + +/// Once the current local hour has reached the user's chosen hour (today), scan +/// `day_local`'s day-tasks and auto-generate a worklog DRAFT (never approve, +/// never post) for any past [`sweep::QUALIFYING_MINUTES`] that doesn't have a draft +/// yet. A no-op when `worklog_auto_generate_time` is unset, or before that hour. +/// Safe to call again later the same day (e.g. the machine was asleep at the +/// chosen time) — see the module docs. Tasks are processed one at a time, in +/// order — never concurrently. +#[tracing::instrument(skip(pool, config))] +pub async fn maybe_auto_generate(pool: &SqlitePool, config: &Config, day_local: &str) { + let span = tracing::info_span!( + "worklog.auto_generate.run", + day = day_local, + chosen_time = Empty, + gate = Empty, + tasks_total = Empty, + tasks_qualifying = Empty, + tasks_already_drafted = Empty, + tasks_drafted = Empty, + tasks_failed = Empty, + ); + run(pool, config, day_local).instrument(span).await +} + +async fn run(pool: &SqlitePool, config: &Config, day_local: &str) { + let current_span = tracing::Span::current(); + + let settings = meridian_core::settings::load_runtime_settings(); + let Some(chosen_time) = settings.worklog_auto_generate_time else { + current_span.record("gate", "disabled"); + return; + }; + current_span.record("chosen_time", chosen_time.as_str()); + + let Some((chosen_hour, _)) = parse_hh_mm(&chosen_time) else { + current_span.record("gate", "malformed_time"); + tracing::warn!( + chosen_time, + "worklog: auto-generate time is malformed — skipping" + ); + return; + }; + if Local::now().hour() < chosen_hour { + current_span.record("gate", "before_chosen_hour"); + return; + } + current_span.record("gate", "ran"); + + draft_qualifying_tasks(pool, config, day_local, ¤t_span).await; +} + +/// The "Generate now" path — the same day-task draft sweep as [`maybe_auto_generate`], +/// but WITHOUT the time-of-day gate, run because the user asked for it on the spot +/// (the daily-summary screen's "Generate now" button) rather than because the clock +/// reached their chosen time. Everything else is identical: drafts only, never +/// approves/posts, and skips any task that already has a draft, so it is safe to run +/// alongside or before the scheduled pass. Independent of `worklog_auto_generate_time`: +/// a user who never set a time can still generate on demand. +#[tracing::instrument(skip(pool, config))] +pub async fn generate_now(pool: &SqlitePool, config: &Config, day_local: &str) { + let span = tracing::info_span!( + "worklog.generate_now.run", + day = day_local, + gate = Empty, + tasks_total = Empty, + tasks_qualifying = Empty, + tasks_already_drafted = Empty, + tasks_drafted = Empty, + tasks_failed = Empty, + ); + async { + let current_span = tracing::Span::current(); + current_span.record("gate", "ran"); + draft_qualifying_tasks(pool, config, day_local, ¤t_span).await; + } + .instrument(span) + .await +} + +#[cfg(test)] +mod tests { + use super::parse_hh_mm; + + #[test] + fn parses_valid_times() { + assert_eq!(parse_hh_mm("00:00"), Some((0, 0))); + assert_eq!(parse_hh_mm("18:00"), Some((18, 0))); + assert_eq!(parse_hh_mm("23:59"), Some((23, 59))); + assert_eq!(parse_hh_mm("09:05"), Some((9, 5))); + } + + #[test] + fn rejects_out_of_range_or_malformed_values() { + assert_eq!(parse_hh_mm("24:00"), None); + assert_eq!(parse_hh_mm("18:60"), None); + assert_eq!(parse_hh_mm("18"), None); + assert_eq!(parse_hh_mm("18:00:00"), None); + assert_eq!(parse_hh_mm(""), None); + assert_eq!(parse_hh_mm("not-a-time"), None); + assert_eq!(parse_hh_mm("ab:cd"), None); + } +} diff --git a/src/pm_worklog/auto_generate.rs b/src/pm_worklog/auto_generate/sweep.rs similarity index 53% rename from src/pm_worklog/auto_generate.rs rename to src/pm_worklog/auto_generate/sweep.rs index 8e693e819..9586e0431 100644 --- a/src/pm_worklog/auto_generate.rs +++ b/src/pm_worklog/auto_generate/sweep.rs @@ -1,96 +1,11 @@ //ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity -//! Auto-generate worklog DRAFTS, once a day, at a user-chosen local clock time — -//! the opt-in behind `settings.worklog_auto_generate_time`. -//! -//! This runs the exact same action as the day-task detail panel's "Generate -//! worklog" button (`pm_worklog::generate`), automatically, for any task past the -//! fixed [`QUALIFYING_MINUTES`] threshold that doesn't have a draft yet. It NEVER -//! approves or posts — approving is always a deliberate human click -//! (`pm_worklog::approve`, the panel's "Approve & post"). Auto-generate only saves -//! the user the click that starts the draft; the tracker is never touched without -//! them. -//! -//! Off by default: the field is `None` until the user picks a time from the -//! Timeline nudge or Settings → Worklogs, so nothing here ever runs unattended for -//! someone who hasn't said yes. -//! -//! # A TRACKER IS NOT REQUIRED — and used to be, wrongly -//! Both entry points opened with `if config.pm_providers.is_empty() { return }`, -//! on the premise that "there is nothing to match against, so the sweep would only -//! fail every call". That premise is false, and stopped being true the moment -//! personal tasks became first-class: [`crate::pm_worklog::generate`] matches a -//! PERSONAL (`local`) day-task exactly like a real ticket and, on approve, posts to -//! that task's own row (`post_to_local_task`) — no tracker anywhere in the path. -//! Only the PROPOSE branch needs a configured provider, and that branch failing is -//! already a per-task `warn!` the loop walks past. -//! -//! So the gate silently disabled the whole feature for every tracker-less user — -//! the cohort Meridian now leads with — and Settings mirrored it with a dead-end -//! card offering nothing. Removed at both ends. Keep it that way: the correct floor -//! is "does this task have qualifying minutes and no draft yet", which is what the -//! sweep already checks, not "does this user have Jira". -//! -//! # Once a day, from the chosen time onward — not continuously, and self-healing -//! [`maybe_auto_generate`] is called every clock-aligned wake (HH:03, same as the -//! rest of the worklog pipeline), but only actually scans once the current local -//! hour has REACHED the chosen time's hour — so a task crossing the threshold at -//! 2pm with a 6pm setting waits for 6pm, not the next tick after 2pm. -//! -//! Deliberately `>=`, not `==`: if the Mac is asleep, off, or the app isn't -//! running at exactly 9pm, there is no tick at 9pm to miss — the daemon simply -//! catches up on its next wake that same day (10pm, or whenever the machine comes -//! back), because every tick from the chosen hour through local midnight re-runs -//! the same scan. This is safe to repeat because already-drafted tasks are -//! skipped (see below) — a caught-up run only ever drafts what genuinely wasn't -//! drafted yet, it never redoes work. The one thing this does NOT do is reach -//! back across local midnight: if the whole rest of the day was slept through, -//! that day's catch-up is skipped, matching this pipeline's standing "no -//! backfill past today" policy — the manual "Generate worklog" button in the -//! panel always still works for any past day. -//! -//! # Fires once per task, not on every qualifying tick -//! Once a task has ANY draft (auto- or manually generated), this leaves it alone -//! for good — it does not keep re-drafting an already-drafted task on a later -//! tick, whether that's later the same evening (the catch-up case above) or a -//! future day. If the user keeps working the task past the auto-draft, getting -//! an up-to-date version is a manual "Regenerate" click in the panel (which -//! shows when the draft was last generated, via `DayTaskWorklogDraft:: -//! updated_at`) — auto-generate hands off to that rather than looping forever. -//! -//! # One task at a time -//! Tasks are processed in a plain sequential loop, not fanned out — each `generate` -//! call is a real LLM request, and running them one after another (rather than -//! concurrently) keeps this predictable and easy to reason about from the trace. -//! -//! # Observability -//! The whole run is one `worklog.auto_generate.run` span (see -//! [`maybe_auto_generate`]) carrying the gate outcome and per-run counts as -//! attributes — `gate` names exactly why a run did or didn't scan (`disabled`, -//! `before_chosen_hour`, `malformed_time`, or `ran`), so "why didn't this fire -//! tonight" is answerable from the trace alone, not by reading code. Each task -//! that actually gets drafted (or fails to) gets its own `info!`/`warn!` — real, -//! individually actionable events — but a task skipped for already having a -//! draft or not yet qualifying is silent (folded into the summary counts only), -//! so a quiet evening with nothing to do doesn't spam the log with one line per -//! task. -//! -//! # Who calls this -//! [`crate::worklog_pipeline::run_loop`], every clock-aligned wake (HH:03), right -//! after the hourly activity-report pass. -//! -//! # Related -//! - [`crate::pm_worklog::generate`] — the exact function the manual button calls; -//! this module never calls [`crate::pm_worklog::approve`]. -//! - [`meridian_core::day_tasks::get_day_tasks`] — the read model this scans; -//! `DayTask::minutes` is the deterministic measured total. -//! - [`meridian_core::day_task_worklogs::get_day_task_worklog`] — the "has this -//! task already been drafted" check that makes auto-generate fire exactly once -//! per task. +//! The shared sweep body [`super::maybe_auto_generate`] (clock-gated) and +//! [`super::generate_now`] (on demand) both call: draft a worklog for every +//! qualifying, not-yet-drafted day-task. Split out of `auto_generate.rs` when that +//! file passed the repo's 500-line cap — see `auto_generate/mod.rs`'s module doc +//! for the full feature description; this file is the mechanical loop only. -use chrono::{Local, Timelike}; use sqlx::SqlitePool; -use tracing::field::Empty; -use tracing::Instrument; use crate::config::Config; use crate::pm_worklog; @@ -98,101 +13,14 @@ use crate::pm_worklog; /// Fixed qualifying threshold — a day-task needs more than this many tracked /// minutes to be auto-drafted. Not user-configurable: only WHEN Meridian checks /// (`worklog_auto_generate_time`) is a choice; WHICH tasks qualify is not. -const QUALIFYING_MINUTES: i64 = 30; - -/// Parse "HH:MM" into `(hour, minute)`. `None` for anything malformed — the -/// settings write path (`update_settings`) already rejects a bad value, so this -/// only has to be defensive against a hand-edited file. -fn parse_hh_mm(s: &str) -> Option<(u32, u32)> { - let (h, m) = s.split_once(':')?; - let (h, m) = (h.parse::().ok()?, m.parse::().ok()?); - (h < 24 && m < 60).then_some((h, m)) -} - -/// Once the current local hour has reached the user's chosen hour (today), scan -/// `day_local`'s day-tasks and auto-generate a worklog DRAFT (never approve, -/// never post) for any past [`QUALIFYING_MINUTES`] that doesn't have a draft yet. -/// A no-op when `worklog_auto_generate_time` is unset, or before that hour. Safe -/// to call again later the same day (e.g. the machine was asleep at the chosen -/// time) — see the module docs. Tasks are processed one at a time, in order — -/// never concurrently. -#[tracing::instrument(skip(pool, config))] -pub async fn maybe_auto_generate(pool: &SqlitePool, config: &Config, day_local: &str) { - let span = tracing::info_span!( - "worklog.auto_generate.run", - day = day_local, - chosen_time = Empty, - gate = Empty, - tasks_total = Empty, - tasks_qualifying = Empty, - tasks_already_drafted = Empty, - tasks_drafted = Empty, - tasks_failed = Empty, - ); - run(pool, config, day_local).instrument(span).await -} - -async fn run(pool: &SqlitePool, config: &Config, day_local: &str) { - let current_span = tracing::Span::current(); - - let settings = meridian_core::settings::load_runtime_settings(); - let Some(chosen_time) = settings.worklog_auto_generate_time else { - current_span.record("gate", "disabled"); - return; - }; - current_span.record("chosen_time", chosen_time.as_str()); - - let Some((chosen_hour, _)) = parse_hh_mm(&chosen_time) else { - current_span.record("gate", "malformed_time"); - tracing::warn!( - chosen_time, - "worklog: auto-generate time is malformed — skipping" - ); - return; - }; - if Local::now().hour() < chosen_hour { - current_span.record("gate", "before_chosen_hour"); - return; - } - current_span.record("gate", "ran"); - - draft_qualifying_tasks(pool, config, day_local, ¤t_span).await; -} - -/// The "Generate now" path — the same day-task draft sweep as [`maybe_auto_generate`], -/// but WITHOUT the time-of-day gate, run because the user asked for it on the spot -/// (the daily-summary screen's "Generate now" button) rather than because the clock -/// reached their chosen time. Everything else is identical: drafts only, never -/// approves/posts, and skips any task that already has a draft, so it is safe to run -/// alongside or before the scheduled pass. Independent of `worklog_auto_generate_time`: -/// a user who never set a time can still generate on demand. -#[tracing::instrument(skip(pool, config))] -pub async fn generate_now(pool: &SqlitePool, config: &Config, day_local: &str) { - let span = tracing::info_span!( - "worklog.generate_now.run", - day = day_local, - gate = Empty, - tasks_total = Empty, - tasks_qualifying = Empty, - tasks_already_drafted = Empty, - tasks_drafted = Empty, - tasks_failed = Empty, - ); - async { - let current_span = tracing::Span::current(); - current_span.record("gate", "ran"); - draft_qualifying_tasks(pool, config, day_local, ¤t_span).await; - } - .instrument(span) - .await -} +pub(super) const QUALIFYING_MINUTES: i64 = 30; /// What one sweep did. Returned rather than only recorded onto the span so the /// behaviour is assertable: "did this run at all" and "did it early-return" are /// otherwise indistinguishable from outside, which is exactly how a tracker gate /// silently disabled the whole feature for solo users once already. #[derive(Debug, Default, PartialEq, Eq)] -struct SweepCounts { +pub(super) struct SweepCounts { total: usize, qualifying: u32, already_drafted: u32, @@ -201,19 +29,36 @@ struct SweepCounts { } /// Draft a worklog for every qualifying, not-yet-drafted day-task of `day_local`, -/// recording per-run counts onto `span` and returning them. The shared body of -/// [`maybe_auto_generate`] (gated on the clock) and [`generate_now`] (on demand); it -/// assumes its caller has already decided a run is warranted. +/// recording per-run counts onto `span`. The shared body of +/// [`super::maybe_auto_generate`] (gated on the clock) and [`super::generate_now`] +/// (on demand); it assumes its caller has already decided a run is warranted. /// /// Deliberately NOT gated on a connected tracker - a personal day-task is matched and /// drafted like any ticket, and only the propose branch needs one (it already fails /// per-task). See [`neither_sweep_is_gated_on_a_connected_tracker`]. -async fn draft_qualifying_tasks( +pub(super) async fn draft_qualifying_tasks( pool: &SqlitePool, config: &Config, day_local: &str, span: &tracing::Span, ) -> SweepCounts { + // Matching a day-task against `pm_tasks` wants current ticket state, and there is + // no background poller keeping it fresh anymore — so this sweep asks for it. + // + // UNATTENDED on purpose. This runs on a clock (HH:03), which makes it the one + // remaining path that could fire a Jira OAuth refresh with nobody at the machine + // — the exact shape that permanently killed a production user's grant when a + // refresh POST straddled a 28-minute suspend. `run_pm_sync_unattended` will use a + // valid access token but never mint one, so an expired token simply defers to the + // next attended request instead of betting the grant. Best-effort either way: a + // sync failure must not block drafting against whatever is cached. + if let Err(e) = crate::intelligence::run_pm_sync_unattended(pool, config).await { + tracing::warn!( + day = day_local, error = %e, + "worklog: auto-generate pm sync failed — matching against cached tasks" + ); + } + let tasks = match meridian_core::day_tasks::get_day_tasks(pool, day_local).await { Ok(resp) => resp.tasks, Err(e) => { @@ -305,7 +150,7 @@ async fn draft_qualifying_tasks( #[cfg(test)] mod tests { - use super::{draft_qualifying_tasks, parse_hh_mm, SweepCounts, QUALIFYING_MINUTES}; + use super::{draft_qualifying_tasks, SweepCounts, QUALIFYING_MINUTES}; use crate::config::Config; use sqlx::sqlite::SqlitePoolOptions; use sqlx::SqlitePool; @@ -449,25 +294,6 @@ mod tests { assert_eq!(counts, SweepCounts::default()); } - #[test] - fn parses_valid_times() { - assert_eq!(parse_hh_mm("00:00"), Some((0, 0))); - assert_eq!(parse_hh_mm("18:00"), Some((18, 0))); - assert_eq!(parse_hh_mm("23:59"), Some((23, 59))); - assert_eq!(parse_hh_mm("09:05"), Some((9, 5))); - } - - #[test] - fn rejects_out_of_range_or_malformed_values() { - assert_eq!(parse_hh_mm("24:00"), None); - assert_eq!(parse_hh_mm("18:60"), None); - assert_eq!(parse_hh_mm("18"), None); - assert_eq!(parse_hh_mm("18:00:00"), None); - assert_eq!(parse_hh_mm(""), None); - assert_eq!(parse_hh_mm("not-a-time"), None); - assert_eq!(parse_hh_mm("ab:cd"), None); - } - /// Neither entry point may re-acquire a "do they have a tracker" precondition. /// /// Source-level because the gate was a bare early return with no observable @@ -477,7 +303,10 @@ mod tests { /// ticket); only the propose branch needs one, and it already fails per-task. #[test] fn neither_sweep_is_gated_on_a_connected_tracker() { - let src = include_str!("auto_generate.rs"); + // Both files: `maybe_auto_generate`/`generate_now` (the entry points) live in + // `mod.rs`, the actual per-task loop lives here — a gate could reappear in + // either. + let src = concat!(include_str!("mod.rs"), "\n", include_str!("sweep.rs")); // Comment lines dropped, not just their markers - the module doc above // quotes the removed gate verbatim to explain why it went. let code: String = src diff --git a/tray/src-tauri/src/commands/integrations.rs b/tray/src-tauri/src/commands/integrations.rs index 5dbe8b53a..2e6e1d502 100644 --- a/tray/src-tauri/src/commands/integrations.rs +++ b/tray/src-tauri/src/commands/integrations.rs @@ -681,6 +681,11 @@ pub async fn save_integration_token( tracing::debug!("daemon reload after token save (non-fatal — will pick up on next start)"); } + // First-time (or credential-change) connect — force a sync so the board + // populates immediately rather than waiting for the next on-demand trigger. + // There's no stale cache to protect here, so gating buys nothing. + crate::commands::tasks::trigger_background_pm_force_sync(db_pool.get(), "token_connected"); + Ok(serde_json::json!({ "ok": true, "reloaded": reloaded })) } @@ -1198,7 +1203,7 @@ pub async fn start_oauth( db_pool: State<'_, crate::db_pool::DbPool>, ) -> Result { match body.provider.as_str() { - "jira" | "trello" => start_oauth_in_process(body.provider), + "jira" | "trello" => start_oauth_in_process(body.provider, db_pool.get()), "github" => start_oauth_github_device(body.provider, db_pool.inner().clone()).await, other => Err(format!("Unknown provider: {other}")), } @@ -1251,7 +1256,10 @@ pub async fn cancel_oauth(body: CancelOAuthBody) -> Result<(), String> { /// functions — avoiding `std::env::set_var` on a Tokio worker thread (POSIX /// setenv is not thread-safe under concurrent env reads). A per-provider /// [`AtomicBool`] prevents two flows from racing to bind the same loopback port. -fn start_oauth_in_process(provider: String) -> Result { +fn start_oauth_in_process( + provider: String, + db: Option, +) -> Result { // Resolve credentials from .env WITHOUT mutating process env. let mode = crate::install::detect_install_mode(); let dot_env = mode.env_path().map(parse_env).unwrap_or_default(); @@ -1343,7 +1351,12 @@ fn start_oauth_in_process(provider: String) -> Result tracing::info!(provider = %task_provider, "in-process OAuth login succeeded"), + Ok(()) => { + tracing::info!(provider = %task_provider, "in-process OAuth login succeeded"); + // First-time connect — force a sync so the board populates + // immediately rather than waiting for the next on-demand trigger. + crate::commands::tasks::trigger_background_pm_force_sync(db, "oauth_connected"); + } Err(e) => { let msg = format!("{e:#}"); tracing::warn!(provider = %task_provider, error = %msg, "in-process OAuth login failed"); @@ -1457,10 +1470,19 @@ async fn start_oauth_github_device( return; } tracing::info!("GitHub device-flow login succeeded"); + // Grab the DB handle BEFORE `db_pool` is moved into the reload below, + // so the sync request can still be written afterwards. + let sync_db = db_pool.get(); // Best-effort reload so the token takes effect now, not next restart. if let Err(e) = crate::commands::daemon::reload_daemon_with(db_pool).await { tracing::debug!(error = %e, "daemon reload after GitHub connect (non-fatal)"); } + // First-time connect — force a sync so the board populates + // immediately rather than waiting for the next on-demand trigger. + crate::commands::tasks::trigger_background_pm_force_sync( + sync_db, + "oauth_connected", + ); } Err(e) => { let msg = format!("{e:#}"); diff --git a/tray/src-tauri/src/commands/tasks.rs b/tray/src-tauri/src/commands/tasks.rs index 882eb515e..90e8d6091 100644 --- a/tray/src-tauri/src/commands/tasks.rs +++ b/tray/src-tauri/src/commands/tasks.rs @@ -1,29 +1,72 @@ //ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity -//! Task-board action commands — the ported `/api/tasks/sync` POST. +//! Task-board action commands — the ported `/api/tasks/sync` POST, plus the +//! on-demand sync triggers every other tray command fires into. //! -//! Re-syncs the PM board: spawns `meridian tasks-sync`, which pulls the latest -//! tickets from the connected tracker into `pm_tasks`. Tracker auth lives in the -//! daemon, so — like every tracker write — this shells out to the CLI rather than -//! talking to the provider directly; it's a process spawn, so it lives tray-side, -//! not in meridian-core. (The per-task *read*, `get_tasks`, stays in -//! [`crate::commands::dashboard`].) +//! There is no background poller anymore: syncing `pm_tasks` from the connected +//! tracker happens only at genuine on-demand moments — the manual "Sync now" +//! button ([`sync_tasks`], force), connecting a tracker +//! ([`trigger_background_pm_force_sync`], force, fire-and-forget), plan mount and +//! the match-to-ticket picker ([`request_gated_sync_tasks`], gated, waits for +//! result). +//! +//! # The tray does not sync, it ASKS +//! +//! Every function here writes a row to `pm_sync_requests` and the **daemon** does the +//! work. The tray never holds a tracker credential, and it no longer spawns a +//! `meridian` process to borrow one either. +//! +//! That is not tidiness, it is the fix for a production incident. An Atlassian OAuth +//! refresh token is single-use and rotating: the old token dies the instant the new +//! one is issued, so a lost response leaves the grant recoverable only inside a +//! 10-minute window and permanently dead after it. The credential therefore has +//! exactly one safe writer — and it used to have several (daemon, tray in-process, +//! plus a fresh CLI process per trigger). The advisory file lock meant to serialise +//! them could not: its 10 s timeout is shorter than the ~26 s a refresh can take, and +//! on timeout the code proceeded WITHOUT the lock. Only Atlassian's grace window kept +//! that from corrupting state. +//! +//! Requesting instead of doing also removed a per-trigger process spawn: opening the +//! dashboard used to start a whole `meridian pm-sync` process — process init, DB +//! open, config load — which then usually discovered the staleness gate and did +//! nothing. It is now one coalescing UPSERT. +//! +//! (The per-task *read*, `get_tasks`, stays in [`crate::commands::dashboard`], and +//! deliberately never triggers a sync itself — it's polled every 30-60s while a panel +//! is mounted, and wiring a sync to that would silently rebuild the background-timer +//! problem this replaces.) //! //! # Who calls this -//! Registered in `lib.rs`'s `invoke_handler!`; consumed by `TasksView.tsx`'s Sync -//! button via `ui/lib/bridge.ts::mutate` (success → re-fetch; error → inline msg). +//! [`sync_tasks`] and [`request_gated_sync_tasks`] are registered in `lib.rs`'s +//! `invoke_handler!`. `sync_tasks` (force) backs every "Sync now" — the Tasks panel, +//! the planner's Refresh chip, and the per-provider button in Settings — via +//! `ui/lib/taskSync.ts::syncTasks`. `request_gated_sync_tasks` backs +//! `taskSync.ts::requestGatedTaskSync`, called by `PlanView` on mount and +//! `WorklogTicketPicker` on open. +//! +//! [`trigger_background_pm_force_sync`] is a plain Rust fn (not a Tauri command), +//! called from `commands::integrations`'s connect-success paths. +//! +//! Nothing calls a gated sync from a window opener any more: `open_dashboard` and +//! `tray::open_native_dashboard` used to, and no longer do. //! //! # Related -//! - [`crate::install::meridian_bin`] — the shared native-first binary resolver. -//! - [`crate::commands::parents`] — the other read-side `meridian` CLI shell-out. +//! - [`meridian_core::pm_sync_requests`] — the request/claim/complete API. +//! - `src/intelligence/sync_requests.rs` — the daemon-side consumer. +//! - `src/intelligence/sync_delegate.rs` — the producer side for the `meridian` CLI +//! subcommands the tray spawns (`tasks-sync`, `pm-sync`, `plan-task-*`, +//! `ticket-update`, `ticket-set-status`, `worklog-generate`), which delegate to the +//! same outbox when a daemon is running, for the same reason. -use meridian_core::proc_ext::NoWindow; +use meridian_core::pm_sync_requests::{self, SyncMode, ALL_PROVIDERS}; +use meridian_core::SqlitePool; use serde::Serialize; use std::time::Duration; -/// How long `meridian tasks-sync` gets before the command gives up and reports -/// a timeout. Named so the log field, the user-facing message and the timer can -/// never disagree — they were three independent literals, and a `30` that drifts -/// in one place turns a support report into a wrong-duration red herring. +/// How long [`ask_daemon_to_sync`] waits for the daemon to report an outcome, and the +/// budget for the no-daemon CLI fallback. Named so the log field, the user-facing +/// message and the timer can never disagree — they were three independent literals, +/// and a `30` that drifts in one place turns a support report into a wrong-duration +/// red herring. const SYNC_TIMEOUT: Duration = Duration::from_secs(30); /// Success payload — mirrors the route's `{ ok, detail }` (the CLI's stdout). @@ -33,83 +76,209 @@ pub struct SyncResult { pub detail: String, } -/// Re-sync the board from the tracker (the ported /api/tasks/sync POST). Spawns -/// `meridian tasks-sync` with a `SYNC_TIMEOUT` budget; returns its trimmed stdout as -/// `detail` on success, or an `Err` carrying stderr (the route's 500 body.error) -/// on timeout / spawn failure / non-zero exit. +/// How often [`ask_daemon_to_sync`] re-reads the request row while waiting. The +/// daemon's watcher ticks every 2 s, so this is matched to that rather than being +/// tighter — a faster poll would just burn reads without seeing a result any sooner. +const OUTCOME_POLL_INTERVAL: Duration = Duration::from_millis(500); + +/// Resolve the tray's DB handle, or an error string suitable for returning straight +/// to the frontend. `None` means the pool is closed (a repair or a corrupt DB), which +/// is a real condition rather than a bug — say so plainly instead of unwrapping. +fn require_pool( + pool: &tauri::State<'_, crate::db_pool::DbPool>, +) -> Result { + pool.get() + .ok_or_else(|| "the database is not open - Meridian may be repairing it".to_string()) +} + +/// Re-sync the board from the tracker (the ported /api/tasks/sync POST) — always +/// forces a fetch, bypassing the per-provider staleness gate, because the user +/// explicitly asked for fresh data right now. +/// +/// Asks the daemon rather than syncing here. The tray must never hold the tracker +/// credential: the Jira refresh token is single-use and rotating, so two processes +/// spending it can permanently kill the grant, and the daemon is the designated sole +/// owner (see [`meridian_core::pm_sync_requests`]). This writes a `force` request, +/// then polls the row for the outcome so the button can still report a real result. #[tauri::command] -#[tracing::instrument] -pub async fn sync_tasks() -> Result { - let bin = crate::install::meridian_bin(); - // The cwd picks the credentials, because dotenvy walks up from it: a release - // build lands on the canonical ~/.meridian/.env (AZURE_DEVOPS_PAT, JIRA_URL, …), - // a dev build on the checkout's own. Never inherit the tray's cwd — under a - // packaged .app that is inside the bundle, and dotenvy finds no .env at all. - let cwd = crate::install::cli_cwd()?; - // WHICH binary ran, and from where, are the two facts that make a failure here - // legible: a stale installed CLI against a DB the dev daemon migrated ahead - // exits non-zero with nothing but `status=Some(1)` in the log otherwise. - tracing::debug!(bin = %bin, cwd = %cwd.display(), "tasks-sync: spawning"); - let child = tokio::process::Command::new(&bin) - .arg("tasks-sync") - .current_dir(&cwd) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - // On timeout below, `tokio::time::timeout` drops the output future; without - // this the orphaned `meridian tasks-sync` keeps running (and can still mutate - // the board) after the UI reports a failure. The deleted /api/tasks/sync route - // called child.kill() on its 30s timer — kill_on_drop preserves that contract. - .kill_on_drop(true) - .no_window() - .output(); +#[tracing::instrument(skip(pool))] +pub async fn sync_tasks( + pool: tauri::State<'_, crate::db_pool::DbPool>, +) -> Result { + let db = require_pool(&pool)?; + match ask_daemon_to_sync(&db, SyncMode::Force, "sync_now", "tasks-sync").await? { + Some(result) => Ok(result), + // The user pressed a button and is watching a spinner, so a timeout has to + // SAY something — but not "failed": the request is queued and the daemon will + // service it. This is the one caller that reports the wait as an error. + None => Err(format!( + "the sync is still running after {}s - it will finish in the background", + SYNC_TIMEOUT.as_secs() + )), + } +} + +/// Frontend-facing: request a **gated** sync and wait for the outcome. +/// +/// # Only two screens should call this +/// +/// The board is rendered in many places, but only two of them make a *decision from +/// the whole task list*, and a stale board there is not a cosmetic lag: +/// +/// - **The daily plan**, where the user picks the day's tickets. A ticket assigned an +/// hour ago is simply ABSENT from the list they can pick from, so it cannot enter +/// the plan at all. (`PlanView`, on mount.) +/// - **The retarget / match-to-existing-ticket picker**, which lists every open +/// ticket by definition. (`WorklogTicketPicker`, on open.) +/// +/// Everything else showing the board is display and self-corrects on the next real +/// trigger. In particular **worklog drafting does NOT need this**: matching reads the +/// day's *plan* as its candidate pool, not the board (see +/// `src/pm_worklog/generate.rs::fetch_plan_candidates` — "Not the board"), so a +/// fresher board cannot widen the candidate set by even one ticket. +/// +/// # Gated, and never on a poll +/// +/// `Gated` rather than `Force` because these fire on mount/open, which recurs: the +/// daemon applies the per-provider staleness window, so a re-open inside it costs one +/// coalescing UPSERT instead of a tracker call. `sync_tasks` is the forced variant and +/// belongs to buttons the user pressed. +/// +/// **Never wire this to a polled read.** `PlanView` re-loads every 30 s and +/// `TasksPanel` every 60 s; attaching a sync to those would rebuild the +/// background-timer problem this whole design removed, relocated into the read path. +/// Mount and click are one-shot; a poll is not. +#[tauri::command] +#[tracing::instrument(skip(pool))] +pub async fn request_gated_sync_tasks( + pool: tauri::State<'_, crate::db_pool::DbPool>, +) -> Result { + let db = require_pool(&pool)?; + // A timeout here is NOT an error, unlike [`sync_tasks`]. Nothing is watching a + // spinner: the screen already rendered from cache and only wanted fresher rows. + // Returning `Err` would make the frontend log a failure for a sync that is simply + // still going, which is noise in the telemetry spool rather than information. + Ok( + ask_daemon_to_sync(&db, SyncMode::Gated, "plan_or_picker", "pm-sync") + .await? + .unwrap_or(SyncResult { + ok: false, + detail: "still running in the background".to_string(), + }), + ) +} - let output = match tokio::time::timeout(SYNC_TIMEOUT, child).await { - Err(_) => { - // `kill_on_drop` reaps the child here, taking its stderr with it, so - // this log is the ONLY record a timeout ever leaves. Emitting a bare - // "tasks-sync timed out" (as it did) makes the two cases that matter - // indistinguishable in a support bundle: a genuinely slow tracker - // sync vs. a `meridian` binary that never got past opening a corrupt - // meridian.db. WHICH binary and WHICH cwd is what separates them — - // the same two facts the spawn/non-zero arms below already log. +/// Ask the daemon to sync and wait up to [`SYNC_TIMEOUT`] for its outcome. +/// +/// `Ok(Some(_))` the daemon reported a result; `Ok(None)` the budget elapsed with the +/// request still queued (not a failure - the daemon will service it); `Err` the sync +/// itself failed, or the outbox could not be read. +/// +/// Shared by [`sync_tasks`] and [`request_gated_sync_tasks`], which differ only in +/// their mode, their tag, their no-daemon fallback subcommand, and what they make of a +/// timeout. Those four params are the entire difference; everything else - the daemon +/// probe, the fallback, the request write, the poll loop - was duplicated line for line +/// between them, which is how the two drifted into having different log messages and +/// only one of them being `#[instrument]`ed. +/// +/// `fallback_cli` is the `meridian` subcommand that performs this same sync in its own +/// process. It is used only when no daemon owns the data dir, where there is no second +/// writer to race for the rotating credential - the tray still never holds a token +/// itself. Without it, a queued row would sit unserviced and the user would watch a +/// spinner time out with the daemon stopped. +#[tracing::instrument(skip(db), fields(mode = mode.as_str()))] +async fn ask_daemon_to_sync( + db: &SqlitePool, + mode: SyncMode, + reason: &'static str, + fallback_cli: &'static str, +) -> Result, String> { + if !crate::commands::daemon_control::status().await.running { + tracing::debug!( + fallback_cli, + "no daemon - delegating to the CLI's own fallback" + ); + let out = + crate::commands::cli_exec::run_meridian(&[fallback_cli], SYNC_TIMEOUT, fallback_cli) + .await?; + return Ok(Some(SyncResult { + ok: true, + detail: out.trim().to_string(), + })); + } + + pm_sync_requests::request(db, ALL_PROVIDERS, mode, reason) + .await + .map_err(|e| format!("could not queue the sync: {e}"))?; + + let deadline = tokio::time::Instant::now() + SYNC_TIMEOUT; + loop { + if tokio::time::Instant::now() >= deadline { tracing::warn!( - bin = %bin, - cwd = %cwd.display(), - timeout_s = SYNC_TIMEOUT.as_secs(), - "tasks-sync timed out" + timeout_s = SYNC_TIMEOUT.as_secs() as i64, + "daemon did not report a sync outcome in time" ); - return Err(format!( - "tasks-sync timed out after {}s", - SYNC_TIMEOUT.as_secs() - )); + return Ok(None); } - Ok(Err(e)) => { - tracing::warn!(bin = %bin, error = %e, "tasks-sync spawn failed"); - return Err(format!("spawn error: {e}")); - } - Ok(Ok(o)) => o, - }; + tokio::time::sleep(OUTCOME_POLL_INTERVAL).await; - if output.status.success() { - let detail = String::from_utf8_lossy(&output.stdout).trim().to_string(); - tracing::info!("tasks-sync ok"); - Ok(SyncResult { ok: true, detail }) - } else { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - // Log the CLI's own reason, not just the exit code. `status=Some(1)` alone - // says nothing — the failure is always explained in stderr, and dropping it - // turned a one-line diagnosis into a manual re-run of the subcommand. - tracing::warn!( - status = ?output.status.code(), - bin = %bin, - stderr = %stderr, - "tasks-sync non-zero" - ); - Err(if stderr.is_empty() { - "tasks-sync failed".to_string() - } else { - stderr - }) + match pm_sync_requests::outcome(db, ALL_PROVIDERS).await { + Ok(Some(out)) => { + if let Some(err) = out.error { + tracing::warn!(error = %err, "daemon reported a sync failure"); + return Err(err); + } + let detail = match out.synced_count { + Some(n) => format!("synced {n} task(s)"), + None => "synced".to_string(), + }; + tracing::debug!(detail = %detail, "sync ok"); + return Ok(Some(SyncResult { ok: true, detail })); + } + Ok(None) => continue, + Err(e) => return Err(format!("could not read the sync outcome: {e}")), + } } } + +/// Fire-and-forget: ask the daemon to **force** a fetch (bypassing the staleness +/// gate) — for a moment where there is no stale cache to protect, e.g. a tracker was +/// just connected and the board should populate immediately rather than wait for the +/// next on-demand trigger. +/// +/// There is deliberately no `trigger_background_pm_sync` (gated) sibling any more. It +/// existed for the dashboard window openers, and those were removed: a window opening +/// is not evidence anyone is about to make a decision from the whole board. The two +/// screens that genuinely are — the daily plan and the retarget ticket picker — ask +/// for themselves through [`request_gated_sync_tasks`]. See that command's doc. +pub(crate) fn trigger_background_pm_force_sync(db: Option, reason: &'static str) { + request_sync(db, SyncMode::Force, reason); +} + +/// Write a sync request, fire-and-forget. +/// +/// Unlike [`sync_tasks`] this does NOT fall back to the CLI when no daemon is running. +/// These fire from window-open and connect-success paths where nothing is waiting on a +/// result, so a queued row that the next daemon start services is the right outcome - +/// spawning a process per window open is exactly the cost this replaced. +/// +/// Takes `Option` (i.e. `DbPool::get()`) rather than a pool or an +/// `AppHandle`, because `None` is a real state and not an error: the pool is closed +/// while a corrupt DB is being repaired. Callers pass what they already hold, which +/// is a `DbPool` in the integration paths and app state in the window paths. +/// +/// Best-effort by design: these fire from window-open and connect-success paths +/// where a failure must never block the thing the user asked for, and the next +/// trigger (or their explicit "Sync now") retries anyway. +fn request_sync(db: Option, mode: SyncMode, reason: &'static str) { + let Some(db) = db else { + tracing::debug!(reason, "pm sync request skipped - database not open"); + return; + }; + tauri::async_runtime::spawn(async move { + match pm_sync_requests::request(&db, ALL_PROVIDERS, mode, reason).await { + Ok(()) => tracing::debug!(reason, mode = mode.as_str(), "pm sync requested"), + Err(e) => tracing::debug!(reason, error = %e, "pm sync request failed"), + } + }); +} diff --git a/tray/src-tauri/src/lib.rs b/tray/src-tauri/src/lib.rs index dc134f10d..a7a621472 100644 --- a/tray/src-tauri/src/lib.rs +++ b/tray/src-tauri/src/lib.rs @@ -1358,6 +1358,7 @@ pub fn run() { // process / service control (ported /api process routes) commands::reload_daemon, commands::sync_tasks, + commands::request_gated_sync_tasks, commands::run_update, // tracker connect/disconnect (ported /api/integrations + /api/auth/oauth) commands::disconnect_integration, diff --git a/ui/components/plan/PlanView.tsx b/ui/components/plan/PlanView.tsx index 3c2bb9fb6..56645f625 100644 --- a/ui/components/plan/PlanView.tsx +++ b/ui/components/plan/PlanView.tsx @@ -25,7 +25,7 @@ import { dayString } from '@/components/timeline/types' import type { PlanResponse, IntegrationsResponse } from '@/lib/api-types' import { MAX_PLAN_TASKS } from '@/lib/api-types' import { load as bridgeLoad } from '@/lib/bridge' -import { lastSyncFailure, syncTasks } from '@/lib/taskSync' +import { lastSyncFailure, requestGatedTaskSync, syncTasks } from '@/lib/taskSync' import { availableTrackerNames, connectedTrackers } from '@/lib/integrations' import { usePlan, refreshPlan, planAction, pausePlanRefresh } from '@/components/plan/planStore' import { isTutorialRunning } from '@/components/tutorial/engine' @@ -219,14 +219,35 @@ export default function PlanView() { useEffect(() => { if (autoSyncedRef.current || !trackersLoaded || !data) return autoSyncedRef.current = true - // Nothing to pull, or something already there to show: the wait is over - // before it started. - if (trackers.length === 0 || (data.available?.length ?? 0) > 0) { setFirstSyncDone(true); return } + // Nothing to pull: the wait is over before it started. + if (trackers.length === 0) { setFirstSyncDone(true); return } + // A POPULATED board still needs a refresh, just not a blocking one. + // + // This branch used to return immediately, on the reasoning that there was + // "something already there to show". True for the empty-board spinner this + // backstop exists for, but it left the planner as the one screen that picks + // from the whole task list while never asking for a current copy of it - so a + // ticket assigned an hour ago was absent from the list, and the only way to + // see it was noticing the Refresh chip. Absent, not stale: it cannot be put in + // today's plan at all. + // + // GATED, unlike the empty branch's forced `handleSync`: this fires on every + // planner open, so the per-provider staleness window is what keeps it from + // becoming a tracker call each time. Not awaited before `setFirstSyncDone` - + // the cached board renders now and the 30 s poll picks the fresher rows up. + if ((data.available?.length ?? 0) > 0) { + void requestGatedTaskSync().then((ok) => { if (ok) void load(false) }) + setFirstSyncDone(true) + return + } // Usually this JOINS the sync the connect flow already started - see `handleSync`. // It stays as a backstop because plenty of empty-board arrivals never went near a // connect: a reopened app, a board that emptied, a sync that failed an hour ago. handleSync().finally(() => setFirstSyncDone(true)) - }, [data, trackers, trackersLoaded, handleSync]) + // `load` is in the deps because the populated branch above calls it directly. + // Re-running is harmless either way: `autoSyncedRef` makes the whole effect + // one-shot per mount. + }, [data, trackers, trackersLoaded, handleSync, load]) // The drag hold-off is module-global, so a planner closed mid-drag (onDragEnd // never fires) would strand every reader's refresh paused for the rest of the diff --git a/ui/components/timeline/WorklogTicketPicker.tsx b/ui/components/timeline/WorklogTicketPicker.tsx index 394963338..94d122d6c 100644 --- a/ui/components/timeline/WorklogTicketPicker.tsx +++ b/ui/components/timeline/WorklogTicketPicker.tsx @@ -23,6 +23,7 @@ import { useEffect, useMemo, useState } from 'react' import { load } from '@/lib/bridge' +import { requestGatedTaskSync } from '@/lib/taskSync' import type { BoardTicket } from '@/lib/api-types' /** Rank the board against a query: key first (people type "KAN-3"), then title, @@ -64,9 +65,22 @@ export function WorklogTicketPicker({ current, busy, onPick, onCancel, title, ex useEffect(() => { if (tickets) return let alive = true - load('/api/board-tickets', 'get_board_tickets') + const read = () => load('/api/board-tickets', 'get_board_tickets') .then(r => { if (alive) setFetched(excludeLocal ? r.filter(t => t.provider !== 'local') : r) }) .catch(() => { if (alive) setError('Could not load your board - try again in a moment.') }) + + // Show the cached board immediately, then ask for a refresh and re-read. + // + // This picker is the ONE surface whose entire purpose is choosing from the whole + // board, so a ticket missing from it is not a cosmetic lag - the user cannot + // retarget onto a ticket that is not listed, and nothing on screen suggests the + // list is incomplete. It had no refresh of its own and relied on whatever some + // other screen had happened to fetch. + // + // Ordered read-then-refresh-then-read on purpose: opening a picker must never + // wait on a network round trip. GATED, so opening it repeatedly stays inside the + // per-provider staleness window rather than hitting the tracker each time. + void read().then(() => requestGatedTaskSync()).then(ok => { if (ok && alive) void read() }) return () => { alive = false } }, [excludeLocal, tickets]) diff --git a/ui/components/timeline/settings/IntegrationsSection.tsx b/ui/components/timeline/settings/IntegrationsSection.tsx index 76fe3acb5..11e2ac66e 100644 --- a/ui/components/timeline/settings/IntegrationsSection.tsx +++ b/ui/components/timeline/settings/IntegrationsSection.tsx @@ -64,7 +64,7 @@ export function IntegrationsSection({ integrations, onChanged, gate = false, onD Connected to {connected.map(t => t.name).join(', ')}

- Syncing every hour + Kept in sync automatically

diff --git a/ui/lib/taskSync.ts b/ui/lib/taskSync.ts index 86fd88dc9..1e593fb1a 100644 --- a/ui/lib/taskSync.ts +++ b/ui/lib/taskSync.ts @@ -137,3 +137,59 @@ export function syncTasks(): Promise { export function pendingTaskSync(): Promise | null { return inFlight } + +/** + * Refresh the board for a screen that is about to make a decision from the WHOLE + * task list, and would behave differently against a stale one. + * + * # Only two screens should call this + * + * - **The daily plan**, where the user picks the day's tickets. A stale board here + * is not a cosmetic lag: a ticket assigned an hour ago is simply absent from the + * list they can pick from, so it cannot enter the plan at all. That is the + * candidate-starvation failure, and it is why this exists. + * - **The retarget / match-to-existing-ticket picker**, which lists every open + * ticket by definition. + * + * Worklog drafting deliberately does NOT call this: matching reads the day's *plan* + * as its candidate pool, not the board (`fetch_plan_candidates` - "Not the board"), + * so a fresher board cannot widen the candidate set by even one ticket. + * + * # Gated, unlike `syncTasks` + * + * `syncTasks` FORCES a fetch, which is right for a button the user pressed and wrong + * for a mount: a screen the user opens repeatedly would hit the tracker every time. + * This goes through `request_gated_sync_tasks`, so the daemon applies the + * per-provider staleness window and a re-open inside it costs one local UPSERT. + * + * **Never call this from a poll.** `PlanView` re-loads every 30 s and `TasksPanel` + * every 60 s; wiring it there would rebuild the background-timer problem this whole + * design removed, relocated into the read path. Mount and click are one-shot. + * + * NEVER REJECTS, for the same reason as `syncTasks`: resolves `true` on a completed + * sync and `false` on a failure. Callers that only want fresher rows can ignore the + * value; nothing on these screens should break because a refresh failed, since the + * cached board still renders. + * + * Deliberately NOT joined to `inFlight`: that promise tracks the *forced* sync, and + * a gated request is a different ask. It is cheap enough (one UPSERT when the window + * is closed) that sharing state would cost more in surprise than in requests. + */ +export function requestGatedTaskSync(): Promise { + return mutate<{ ok: boolean }>('/api/tasks/sync', 'request_gated_sync_tasks', {}) + // `ok: false` means the daemon had not reported back inside its budget - the sync + // is still running, so there is nothing fresher to re-read YET. Reported as + // `false` so callers skip a pointless re-read; it is not a failure and nothing is + // logged for it (unlike the `.catch` below). + .then((r) => r?.ok !== false) + .catch((e) => { + // Reported, not surfaced: unlike the Refresh chip there is no UI element + // waiting to explain this, and the screen renders fine from cache. It still + // has to reach the telemetry spool, or a board that is quietly never + // refreshing looks identical to one that is up to date. + const reason = syncFailureReason(e) + console.error('[meridian] gated tracker sync failed', e) + reportUiError(`gated tracker sync failed: ${reason}`) + return false + }) +} From 9b5057f89eab3980e13d947a70c8abbc185c411c Mon Sep 17 00:00:00 2001 From: adityaharishch Date: Wed, 26 Aug 2026 15:32:23 +0530 Subject: [PATCH 16/53] fix(sync): explain the update window instead of leaking a SQL error `pm_sync_requests` arrives in migration 082, and only the daemon runs migrations (the tray opens meridian.db with `create_if_missing(false)`, assuming the daemon made it). So during an app update there is a window - new tray already running, daemon not yet restarted onto the new binary - where the table does not exist. It lasts seconds and self-heals when the daemon restarts, but a user who pressed "Sync now" inside it was shown `could not queue the sync: no such table: pm_sync_requests`. That reads like database damage for what is a normal transient update state, and it is the kind of message that generates a support ticket about a non-problem. Maps that one case to "Meridian is still finishing an update - try again in a moment". Every other write failure keeps its detail: collapsing them all into the friendly message would hide a real fault (a locked or corrupt DB) behind "try again in a moment", which would never resolve. Three tests pin all three branches, including that a DIFFERENT missing table is not misreported as an update in progress. --- tray/src-tauri/src/commands/tasks.rs | 86 +++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/tray/src-tauri/src/commands/tasks.rs b/tray/src-tauri/src/commands/tasks.rs index 90e8d6091..f626c7bae 100644 --- a/tray/src-tauri/src/commands/tasks.rs +++ b/tray/src-tauri/src/commands/tasks.rs @@ -81,6 +81,35 @@ pub struct SyncResult { /// tighter — a faster poll would just burn reads without seeing a result any sooner. const OUTCOME_POLL_INTERVAL: Duration = Duration::from_millis(500); +/// Turn a failed request-write into something a user can act on. +/// +/// # Why the missing-table case is special-cased +/// +/// `pm_sync_requests` arrives in migration 082, and **only the daemon runs migrations** +/// (the tray opens the file with `create_if_missing(false)` and assumes the daemon made +/// it). So during an app update there is a window — new tray already running, daemon not +/// yet restarted onto the new binary — where the table genuinely does not exist yet. +/// +/// It is seconds long and self-heals the moment the daemon restarts, but a user who +/// presses "Sync now" inside it would otherwise be shown a raw SQL string: +/// `could not queue the sync: no such table: pm_sync_requests`. That reads like +/// database damage for what is in fact a normal, transient update state, and it is the +/// kind of message that produces a support ticket about a non-problem. +/// +/// Matched on the message rather than a typed error because sqlx surfaces this as a +/// `Database` error whose only distinguishing feature IS its text; the match is +/// deliberately loose (table name plus "no such table") so a reworded sqlite message +/// degrades to the generic branch rather than mis-reporting something else. +fn queue_failure_message(e: &anyhow::Error) -> String { + let detail = format!("{e:#}"); + if detail.contains("no such table") && detail.contains("pm_sync_requests") { + tracing::warn!("pm_sync_requests missing - the daemon has not applied migration 082 yet"); + return "Meridian is still finishing an update - try again in a moment".to_string(); + } + tracing::warn!(error = %detail, "could not queue a PM sync request"); + format!("could not queue the sync: {detail}") +} + /// Resolve the tray's DB handle, or an error string suitable for returning straight /// to the frontend. `None` means the pool is closed (a repair or a corrupt DB), which /// is a real condition rather than a bug — say so plainly instead of unwrapping. @@ -207,9 +236,9 @@ async fn ask_daemon_to_sync( })); } - pm_sync_requests::request(db, ALL_PROVIDERS, mode, reason) - .await - .map_err(|e| format!("could not queue the sync: {e}"))?; + if let Err(e) = pm_sync_requests::request(db, ALL_PROVIDERS, mode, reason).await { + return Err(queue_failure_message(&e)); + } let deadline = tokio::time::Instant::now() + SYNC_TIMEOUT; loop { @@ -282,3 +311,54 @@ fn request_sync(db: Option, mode: SyncMode, reason: &'static str) { } }); } + +#[cfg(test)] +mod tests { + use super::*; + + /// The update window: `pm_sync_requests` does not exist yet because the daemon + /// has not applied migration 082. The user must see a transient-update message, + /// never a raw SQL string that reads like database damage. + #[test] + fn a_missing_requests_table_reads_as_a_pending_update() { + let e = anyhow::anyhow!( + "error returned from database: (code: 1) no such table: pm_sync_requests" + ); + + let msg = queue_failure_message(&e); + + assert_eq!( + msg, + "Meridian is still finishing an update - try again in a moment" + ); + assert!(!msg.contains("no such table"), "must not leak SQL: {msg}"); + } + + /// Any OTHER write failure keeps its detail. Collapsing every error into the + /// friendly update message would hide a real fault (a locked or corrupt DB) behind + /// "try again in a moment", which never resolves. + #[test] + fn other_failures_keep_their_detail() { + let e = anyhow::anyhow!("database is locked"); + + let msg = queue_failure_message(&e); + + assert!( + msg.contains("database is locked"), + "detail was dropped: {msg}" + ); + } + + /// A missing table that is NOT ours is somebody else's problem and must not be + /// reported as a pending update - that would send the user to wait out an update + /// that is already finished while the real fault goes unnamed. + #[test] + fn a_different_missing_table_is_not_reported_as_an_update() { + let e = anyhow::anyhow!("no such table: pm_tasks"); + + let msg = queue_failure_message(&e); + + assert!(msg.contains("pm_tasks"), "detail was dropped: {msg}"); + assert!(!msg.contains("finishing an update"), "misattributed: {msg}"); + } +} From d6eec94c4ef85e0cfcc3dfef021730dbbb90eb5f Mon Sep 17 00:00:00 2001 From: adityaharishch Date: Wed, 26 Aug 2026 19:21:03 +0530 Subject: [PATCH 17/53] fix(sync): report sync outcomes by sequence and stop caching db pools Three defects found by reproducing a staging report of "Sync now" failing and `(code: 11) database disk image is malformed` on a database that `meridian db check` reported healthy. 1. Sync reported failure for syncs that had SUCCEEDED. `pm_sync_requests` modelled completion as a flag that `request()` cleared, and `complete()` guarded on `claimed_at IS NOT NULL`, which `request()` had just nulled. A request arriving mid-sync therefore made the running sync's completion match nothing: the outcome was discarded, the work was redone, and every waiter polled its full 30s budget and reported failure. Connecting a tracker fires `oauth_connected`, `token_connected` and the user's own "Sync now" within seconds, so this was the normal case, not an edge one. Migration 083 adds `seq`/`completed_seq`. Completion is now a watermark that is never cleared: a producer holding seq N is satisfied by any `completed_seq >= N`, so overlapping requests coalesce instead of cannibalising each other, one sync can satisfy several waiters, and a request made mid-sync still cannot be marked done by work that predates it. 2. Capture wrote to a closed pool after every daemon reload. The consumers cached an `Option` for the tray process's whole lifetime and wrote through it every ~2.5s. `DbPool::close()` (run by `reload_daemon` around every daemon restart, precisely so the tray holds no connection across one) cannot reach a clone that escaped the handle. They now take the handle and resolve `get()` per write; the same fix applies to two escaped clones in `integrations.rs`. This predates the outbox work. 3. The daemon checkpointed the WAL underneath its own live writes. The sync watcher's `JoinHandle` was dropped, and `service_once` ignores the shutdown flag once it has claimed a row so it can finish - so it could still be writing for tens of seconds while `checkpoint_wal` and `close()` ran. `release_endpoint()` also ran before the checkpoint, opening the single-instance guard while this process was still writing. The watcher is now awaited first and the endpoint released last. The watcher also reads before claiming, so an idle daemon issues no write transactions at all (`claim` is an UPDATE, and SQLite opens one even for a no-op match - roughly 43,000 a day on this 2s tick). Recovery, because a cause may remain unfound: `DbPool::recover_if_corrupt` recycles the pool on a corrupt-view write failure, with a cooldown, so the app heals itself instead of needing a relaunch no user would think to try. It shares one lock with the reload cycle - without that, a recycle could reopen the pool inside the reload's close/signal window and leave a connection spanning two daemon generations, which is the corruption profile the dance exists to prevent. Also: four `error = %e` sites lost their cause chain and now use `cmd_err!`; the schema-pending message covers a missing COLUMN as well as a missing table, since 083 reopens that update window; capture write failures raise the `db.corrupt` banner; and the outbox tests now run the real migrator instead of a hand-written CREATE TABLE that had already drifted. Not covered: every test here runs against in-memory SQLite. The WAL-index desync needs two real processes on a real encrypted file with launchd relaunching the daemon, and was never reproduced in a test. --- meridian-core/src/pm_sync_requests/mod.rs | 267 +++++++++++--- meridian-core/src/pm_sync_requests/tests.rs | 244 +++++++++++-- src/intelligence/sync_delegate.rs | 64 ++-- src/intelligence/sync_requests.rs | 37 +- src/main.rs | 118 +++++- src/migrations/083_pm_sync_request_seq.sql | 35 ++ tray/src-tauri/src/commands/daemon.rs | 48 +++ tray/src-tauri/src/commands/integrations.rs | 18 +- tray/src-tauri/src/commands/pause.rs | 8 +- .../src/commands/{tasks.rs => tasks/mod.rs} | 289 ++++++++++----- tray/src-tauri/src/commands/tasks/tests.rs | 162 +++++++++ tray/src-tauri/src/db_pool.rs | 209 ----------- tray/src-tauri/src/db_pool/mod.rs | 342 ++++++++++++++++++ tray/src-tauri/src/db_pool/tests.rs | 276 ++++++++++++++ tray/src-tauri/src/lib.rs | 127 ++++++- tray/src-tauri/src/poll/mod.rs | 20 +- tray/src-tauri/src/poll/refresh.rs | 99 +---- 17 files changed, 1820 insertions(+), 543 deletions(-) create mode 100644 src/migrations/083_pm_sync_request_seq.sql rename tray/src-tauri/src/commands/{tasks.rs => tasks/mod.rs} (54%) create mode 100644 tray/src-tauri/src/commands/tasks/tests.rs delete mode 100644 tray/src-tauri/src/db_pool.rs create mode 100644 tray/src-tauri/src/db_pool/mod.rs create mode 100644 tray/src-tauri/src/db_pool/tests.rs diff --git a/meridian-core/src/pm_sync_requests/mod.rs b/meridian-core/src/pm_sync_requests/mod.rs index ed070c6b5..8bc72cbb0 100644 --- a/meridian-core/src/pm_sync_requests/mod.rs +++ b/meridian-core/src/pm_sync_requests/mod.rs @@ -74,8 +74,20 @@ pub struct SyncRequest { pub provider: String, pub mode: SyncMode, pub reason: String, + /// The sequence number this claim covers - pass it back to [`complete`]. + /// + /// Captured at claim time on purpose: a request arriving DURING the sync bumps + /// `seq` past this value, so it stays pending and gets its own sync rather than + /// being silently marked done by work that started before it was asked for. + pub seq: i64, } +// Every query below spells "nothing has completed yet" as +// `COALESCE(completed_seq, 0)` and "pending" as `seq > COALESCE(completed_seq, 0)`. +// `seq` starts at 1, so 0 is unreachable as a real watermark. It lives inline in the +// SQL rather than as a Rust constant because it cannot be interpolated into a query +// string without giving up the compile-time-checked literal. + /// Ask the daemon to sync PM tasks. Idempotent and coalescing: repeated calls /// collapse into the single pending row rather than queueing, so opening the /// dashboard ten times means "a sync is wanted", not ten syncs. @@ -83,10 +95,25 @@ pub struct SyncRequest { /// `mode` **escalates only**. A `Force` landing on a pending `Gated` upgrades it, /// because a user who just connected a tracker must not have that downgraded by a /// passing window focus; a `Gated` landing on a pending `Force` leaves the `Force` -/// intact. Writing a new request also clears any previous completion stamps, so the -/// row unambiguously represents work still to do. +/// intact. +/// +/// # Returns the sequence number to wait on +/// +/// Pass the returned `seq` to [`outcome`]. It is what makes concurrent producers +/// safe, and it replaces the previous design where a new request cleared the +/// completion stamps outright. /// -/// `reason` is a producer tag for tracing only (`"dashboard_open"`, +/// Clearing them looked right - the row should represent work still to do - but it +/// destroyed the *answer* to a request already in flight, and the tracker-connect +/// flow always has several in flight at once (`oauth_connected`, +/// `token_connected`, and the user's own "Sync now", within a few seconds). The +/// completion of an earlier sync then matched nothing, the work was redone, and +/// every waiter timed out reporting failure for a sync that had in fact succeeded. +/// +/// So completion is now a **watermark**, never cleared: this only bumps `seq` and +/// re-opens the claim. A holder of seq N is satisfied by any `completed_seq >= N`. +/// +/// `reason` is a producer tag for tracing only (`"plan_or_picker"`, /// `"token_connected"`). Never pass user content - it is read back into logs. #[tracing::instrument(skip(pool))] pub async fn request( @@ -94,84 +121,165 @@ pub async fn request( provider: &str, mode: SyncMode, reason: &str, -) -> Result<()> { +) -> Result { + // A transaction, not `RETURNING`: the upsert and the read-back of `seq` must be + // atomic (a concurrent producer bumping `seq` in between would hand this caller + // a number it never wrote, making it wait on somebody else's sync), and + // `RETURNING` on an upsert needs SQLite 3.35+, which is not worth depending on + // when the SQLCipher build is the thing supplying the library. + let mut tx = pool + .begin() + .await + .context("opening a PM sync request transaction")?; + sqlx::query( "INSERT INTO pm_sync_requests - (provider, mode, reason, requested_at, claimed_at, completed_at, error, synced_count) - VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), NULL, NULL, NULL, NULL) + (provider, mode, reason, requested_at, + claimed_at, completed_at, error, synced_count, seq, completed_seq) + VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), + NULL, NULL, NULL, NULL, 1, NULL) ON CONFLICT(provider) DO UPDATE SET + -- The whole point: a new request is a new sequence number, so the + -- outcome of whatever is already running stays attributable to it. + seq = pm_sync_requests.seq + 1, -- Escalate to 'force', never back down from it while still pending. -- - -- The `completed_at IS NULL` half is load-bearing: the row is kept after a - -- sync finishes (so \"Sync now\" can read its result), so without it a - -- SPENT 'force' would be inherited forever and every later gated request - -- would silently escalate. One tracker connect would then make every - -- planner open bypass the staleness gate and hit the provider for real - + -- The pendingness half is load-bearing: the row is kept after a sync + -- finishes (so \"Sync now\" can read its result), so without it a SPENT + -- 'force' would be inherited forever and every later gated request would + -- silently escalate. One tracker connect would then make every planner + -- open bypass the staleness gate and hit the provider for real - -- reinstating the constant polling this whole design removes, and -- multiplying exactly the token refreshes it exists to reduce. mode = CASE WHEN excluded.mode = 'force' OR (pm_sync_requests.mode = 'force' - AND pm_sync_requests.completed_at IS NULL) + AND pm_sync_requests.seq > COALESCE(pm_sync_requests.completed_seq, 0)) THEN 'force' ELSE excluded.mode END, reason = excluded.reason, requested_at = excluded.requested_at, - -- A fresh request re-opens the row: drop the in-flight and completion - -- marks so the watcher sees pending work again. - claimed_at = NULL, - completed_at = NULL, - error = NULL, - synced_count = NULL", + -- Re-open the claim so the watcher sees pending work, but do NOT touch + -- completed_at / completed_seq / error / synced_count: those describe + -- the last sync that actually ran, and erasing them is what lost + -- outcomes. `seq` above is what marks this as new work. + claimed_at = NULL", ) .bind(provider) .bind(mode.as_str()) .bind(reason) - .execute(pool) + .execute(&mut *tx) .await .context("writing a PM sync request")?; - tracing::debug!(provider, mode = mode.as_str(), reason, "PM sync requested"); - Ok(()) + + let seq: i64 = sqlx::query_scalar("SELECT seq FROM pm_sync_requests WHERE provider = ?") + .bind(provider) + .fetch_one(&mut *tx) + .await + .context("reading back the PM sync request sequence")?; + + tx.commit().await.context("committing a PM sync request")?; + + tracing::debug!( + provider, + mode = mode.as_str(), + reason, + seq, + "PM sync requested" + ); + Ok(seq) +} + +/// Is there work to claim? A pure READ, so an idle consumer touches no locks. +/// +/// # Why this exists rather than just calling [`claim`] +/// +/// [`claim`] is an `UPDATE`, and SQLite opens a write transaction and takes a +/// RESERVED lock to evaluate one even when it matches no rows. The daemon's watcher +/// ticks every 2 s forever, so calling `claim` unconditionally meant **~43,000 write +/// transactions a day on an idle machine** - work that did not exist before this +/// outbox, on a file a second process also writes. Worse than the cost: it made +/// every daemon kill far more likely to land while a write transaction was open, +/// which is the profile behind the `-shm` desync that wedged writes on +/// 1.91.0-staging.2 with `(code: 11) database disk image is malformed` on a database +/// that was provably healthy. +/// +/// Gating on this read takes an idle daemon's write load to zero. WAL readers do not +/// take the write lock, so a tick that finds nothing is genuinely free. +/// +/// **Advisory only.** A `true` here can go stale before [`claim`] runs, and that is +/// fine: `claim` is still the conditional `UPDATE` that decides, so exclusivity is +/// unchanged and a lost race just yields `None`. A `false` that was wrong costs one +/// tick of latency. +pub async fn has_pending(pool: &SqlitePool, provider: &str) -> Result { + let found: Option = sqlx::query_scalar( + "SELECT 1 FROM pm_sync_requests + WHERE provider = ? + AND claimed_at IS NULL + AND seq > COALESCE(completed_seq, 0)", + ) + .bind(provider) + .fetch_optional(pool) + .await + .context("checking for a pending PM sync request")?; + Ok(found.is_some()) } /// Claim the pending request for `provider`, if there is one, marking it in-flight /// so a second watcher tick can't pick up the same work. /// -/// The claim is a conditional UPDATE (`WHERE claimed_at IS NULL AND completed_at IS -/// NULL`) rather than a read-then-write, so two daemons racing on the same file - -/// which the single-instance guard makes unlikely but not impossible during a -/// restart overlap - cannot both claim it. SQLite serialises the statement, so -/// exactly one sees a non-zero `rows_affected`. +/// The claim is a conditional UPDATE (`WHERE claimed_at IS NULL AND `) +/// rather than a read-then-write, so two daemons racing on the same file - which the +/// single-instance guard makes unlikely but not impossible during a restart overlap - +/// cannot both claim it. SQLite serialises the statement, so exactly one sees a +/// non-zero `rows_affected`. +/// +/// Pending is `seq > COALESCE(completed_seq, 0)`, not `completed_at IS NULL`: the +/// completion stamps are a watermark now and are never cleared, so the only thing +/// that makes a row claimable again is [`request`] bumping `seq` past it. +/// +/// Reads `seq` back inside the same transaction as the claim, so the value handed to +/// [`complete`] is exactly the one this claim covers even if a producer bumps it a +/// microsecond later. #[tracing::instrument(skip(pool))] pub async fn claim(pool: &SqlitePool, provider: &str) -> Result> { + let mut tx = pool + .begin() + .await + .context("opening a PM sync claim transaction")?; + let claimed = sqlx::query( "UPDATE pm_sync_requests SET claimed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE provider = ? AND claimed_at IS NULL - AND completed_at IS NULL", + AND seq > COALESCE(completed_seq, 0)", ) .bind(provider) - .execute(pool) + .execute(&mut *tx) .await .context("claiming a PM sync request")?; if claimed.rows_affected() == 0 { + // Nothing to do - roll back rather than commit an empty transaction. return Ok(None); } - let row: Option<(String, String)> = - sqlx::query_as("SELECT mode, reason FROM pm_sync_requests WHERE provider = ?") + let row: Option<(String, String, i64)> = + sqlx::query_as("SELECT mode, reason, seq FROM pm_sync_requests WHERE provider = ?") .bind(provider) - .fetch_optional(pool) + .fetch_optional(&mut *tx) .await .context("reading the claimed PM sync request")?; - Ok(row.map(|(mode, reason)| SyncRequest { + tx.commit().await.context("committing a PM sync claim")?; + + Ok(row.map(|(mode, reason, seq)| SyncRequest { provider: provider.to_string(), mode: SyncMode::from_str_or_gated(&mode), reason, + seq, })) } @@ -179,35 +287,65 @@ pub async fn claim(pool: &SqlitePool, provider: &str) -> Result COALESCE(completed_seq, 0)` keeps the protection and loses the +/// bug. The watermark only ever moves forward, so this is idempotent and a late +/// duplicate cannot roll it back; a newer request has a HIGHER `seq` than the one +/// being completed, so it stays pending and gets its own sync. `claimed_at` is +/// cleared here rather than depended on, which is what makes that next sync +/// claimable immediately. #[tracing::instrument(skip(pool))] pub async fn complete( pool: &SqlitePool, provider: &str, + seq: i64, synced_count: Option, error: Option<&str>, ) -> Result<()> { - sqlx::query( + let res = sqlx::query( "UPDATE pm_sync_requests - SET completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), - error = ?, - synced_count = ? + SET completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), + completed_seq = ?, + claimed_at = NULL, + error = ?, + synced_count = ? WHERE provider = ? - AND claimed_at IS NOT NULL - AND completed_at IS NULL", + AND ? > COALESCE(completed_seq, 0)", ) + .bind(seq) .bind(error) .bind(synced_count) .bind(provider) + .bind(seq) .execute(pool) .await .context("completing a PM sync request")?; + + if res.rows_affected() == 0 { + // Not an error: a duplicate or out-of-order completion for a watermark that + // has already moved past `seq`. Worth a line, because it should be rare and + // a steady stream of them would mean two consumers are running. + tracing::debug!( + provider, + seq, + "PM sync completion ignored - the watermark is already at or past it" + ); + } Ok(()) } @@ -229,7 +367,7 @@ pub async fn reset_stale_claims(pool: &SqlitePool) -> Result { "UPDATE pm_sync_requests SET claimed_at = NULL WHERE claimed_at IS NOT NULL - AND completed_at IS NULL", + AND seq > COALESCE(completed_seq, 0)", ) .execute(pool) .await @@ -244,12 +382,24 @@ pub async fn reset_stale_claims(pool: &SqlitePool) -> Result { Ok(n) } -/// The outcome of the last request for `provider`, for a producer that wants to -/// show one ("Sync now"). `None` while the request is still pending or in flight, -/// so a caller can poll this until it turns `Some`. -pub async fn outcome(pool: &SqlitePool, provider: &str) -> Result> { - let row: Option<(Option, Option, Option)> = sqlx::query_as( - "SELECT completed_at, error, synced_count FROM pm_sync_requests WHERE provider = ?", +/// The outcome for the request the caller wrote, for a producer that wants to show +/// one ("Sync now"). `None` while that request is still pending or in flight, so a +/// caller can poll this until it turns `Some`. +/// +/// `want_seq` is the value [`request`] returned. `Some` means `completed_seq >= +/// want_seq` - i.e. a sync that finished at or after the caller's request, which is +/// what makes overlapping producers safe: two waiters can be satisfied by one sync, +/// and neither can be handed the result of a sync that finished BEFORE it asked. +/// +/// Passing a stale `want_seq` (from a previous request) therefore returns a result +/// immediately, by design - it has genuinely been satisfied. +pub async fn outcome( + pool: &SqlitePool, + provider: &str, + want_seq: i64, +) -> Result> { + let row: Option<(Option, Option, Option)> = sqlx::query_as( + "SELECT completed_seq, error, synced_count FROM pm_sync_requests WHERE provider = ?", ) .bind(provider) .fetch_optional(pool) @@ -257,11 +407,14 @@ pub async fn outcome(pool: &SqlitePool, provider: &str) -> Result Some(SyncOutcome { - error, - synced_count, - }), - // No row, or a row still pending / in flight. + Some((Some(completed_seq), error, synced_count)) if completed_seq >= want_seq => { + Some(SyncOutcome { + error, + synced_count, + }) + } + // No row, nothing completed yet, or the watermark has not reached this + // caller's request. _ => None, }) } diff --git a/meridian-core/src/pm_sync_requests/tests.rs b/meridian-core/src/pm_sync_requests/tests.rs index 2bf861c66..1877f34a7 100644 --- a/meridian-core/src/pm_sync_requests/tests.rs +++ b/meridian-core/src/pm_sync_requests/tests.rs @@ -10,26 +10,23 @@ use super::*; use sqlx::sqlite::SqliteConnectOptions; use std::str::FromStr; +/// The table as the REAL migrations build it, not a hand-written copy. +/// +/// It used to be a hand-written `CREATE TABLE` mirroring migration 082. That is a +/// schema the tests can silently diverge from: adding `seq`/`completed_seq` in +/// migration 083 left every one of these tests passing against a table that did not +/// have the columns the queries now use, so the suite proved nothing about the code +/// that shipped. Running the migrator instead means these tests also assert that +/// 082 + 083 actually apply in order, which is the property real installs depend on. async fn db() -> SqlitePool { let opts = SqliteConnectOptions::from_str("sqlite::memory:") .unwrap() .create_if_missing(true); let pool = SqlitePool::connect_with(opts).await.unwrap(); - sqlx::query( - "CREATE TABLE pm_sync_requests ( - provider TEXT NOT NULL PRIMARY KEY, - mode TEXT NOT NULL DEFAULT 'gated', - reason TEXT NOT NULL DEFAULT '', - requested_at TEXT NOT NULL, - claimed_at TEXT, - completed_at TEXT, - error TEXT, - synced_count INTEGER - )", - ) - .execute(&pool) - .await - .unwrap(); + sqlx::migrate!("../src/migrations") + .run(&pool) + .await + .unwrap(); pool } @@ -92,7 +89,9 @@ async fn a_completed_force_does_not_escalate_the_next_gated_request() { .await .unwrap(); claim(&pool, ALL_PROVIDERS).await.unwrap(); - complete(&pool, ALL_PROVIDERS, Some(4), None).await.unwrap(); + complete(&pool, ALL_PROVIDERS, 1, Some(4), None) + .await + .unwrap(); // A later window open wants the cheap, gated behaviour. request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") @@ -108,8 +107,9 @@ async fn a_completed_force_does_not_escalate_the_next_gated_request() { } /// The in-flight case still escalates: a force that is claimed but not completed -/// will have its outcome discarded by `complete`'s guard and be re-serviced, so -/// the force intent must survive into that re-run. +/// gets a NEW sequence number from the arriving request, so it stays pending past +/// the running sync's completion and is serviced again - and the force intent must +/// survive into that re-run rather than being downgraded to the arriving gated mode. #[tokio::test] async fn an_in_flight_force_still_survives_a_gated_request() { let pool = db().await; @@ -184,7 +184,9 @@ async fn reset_leaves_completed_requests_alone() { .await .unwrap(); claim(&pool, ALL_PROVIDERS).await.unwrap(); - complete(&pool, ALL_PROVIDERS, Some(2), None).await.unwrap(); + complete(&pool, ALL_PROVIDERS, 1, Some(2), None) + .await + .unwrap(); assert_eq!(reset_stale_claims(&pool).await.unwrap(), 0); assert!( @@ -201,16 +203,21 @@ async fn outcome_is_none_until_completed() { request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") .await .unwrap(); - assert!(outcome(&pool, ALL_PROVIDERS).await.unwrap().is_none()); + assert!(outcome(&pool, ALL_PROVIDERS, 1).await.unwrap().is_none()); claim(&pool, ALL_PROVIDERS).await.unwrap(); assert!( - outcome(&pool, ALL_PROVIDERS).await.unwrap().is_none(), + outcome(&pool, ALL_PROVIDERS, 1).await.unwrap().is_none(), "in-flight must still read as pending" ); - complete(&pool, ALL_PROVIDERS, Some(7), None).await.unwrap(); - let out = outcome(&pool, ALL_PROVIDERS).await.unwrap().expect("done"); + complete(&pool, ALL_PROVIDERS, 1, Some(7), None) + .await + .unwrap(); + let out = outcome(&pool, ALL_PROVIDERS, 1) + .await + .unwrap() + .expect("done"); assert_eq!(out.synced_count, Some(7)); assert!(out.error.is_none()); } @@ -223,44 +230,205 @@ async fn outcome_carries_the_error() { .await .unwrap(); claim(&pool, ALL_PROVIDERS).await.unwrap(); - complete(&pool, ALL_PROVIDERS, None, Some("401 unauthorized")) + complete(&pool, ALL_PROVIDERS, 1, None, Some("401 unauthorized")) .await .unwrap(); - let out = outcome(&pool, ALL_PROVIDERS).await.unwrap().expect("done"); + let out = outcome(&pool, ALL_PROVIDERS, 1) + .await + .unwrap() + .expect("done"); assert_eq!(out.error.as_deref(), Some("401 unauthorized")); } -/// THE RACE THIS GUARDS: a request arriving mid-sync resets the row, and the -/// older sync's outcome must NOT stamp it complete - that would mark the new -/// request done without ever servicing it. +/// **THE BUG THIS FILE EXISTS FOR, and both halves of it at once.** +/// +/// A request arriving mid-sync must not be marked done by the sync that was already +/// running (or "Sync now" reports success for work that never ran), AND the sync that +/// was already running must still be able to report its result to whoever asked for +/// it (or every waiter times out and reports failure for a sync that succeeded). +/// +/// The 082 design could only get one of those. It guarded `complete` on `claimed_at +/// IS NOT NULL`, which the new request had just nulled, so the completion was +/// discarded entirely - protecting the new request by throwing away the old +/// request's answer. On 1.91.0-staging.2 that was the normal case rather than an +/// edge one, because connecting a tracker fires `oauth_connected`, +/// `token_connected` and the user's "Sync now" within a few seconds: the sync +/// worked, the answer was dropped, the work was repeated, and the user saw a +/// failure. +/// +/// The sequence watermark gets both. Asserting only the first half is what let the +/// bug ship, so this test asserts them together. #[tokio::test] -async fn completion_does_not_clobber_a_request_that_arrived_mid_sync() { +async fn a_mid_sync_request_neither_steals_nor_destroys_the_running_sync_s_outcome() { let pool = db().await; - request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") + let first = request(&pool, ALL_PROVIDERS, SyncMode::Gated, "plan_or_picker") .await .unwrap(); - claim(&pool, ALL_PROVIDERS).await.unwrap(); + let claimed = claim(&pool, ALL_PROVIDERS).await.unwrap().expect("pending"); + assert_eq!( + claimed.seq, first, + "the claim must cover the request it read" + ); - // A new request lands while the first sync is still running. - request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") + // A second producer asks while the first sync is still running - the connect + // flow does exactly this. + let second = request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") .await .unwrap(); + assert!(second > first, "a new request must advance the sequence"); - // The in-flight sync finishes and tries to report. This must be a NO-OP: - // the new request cleared `claimed_at`, so the guard rejects it. - complete(&pool, ALL_PROVIDERS, Some(3), None).await.unwrap(); + // The in-flight sync finishes and reports against the seq it claimed. + complete(&pool, ALL_PROVIDERS, claimed.seq, Some(3), None) + .await + .unwrap(); + + // Half one - the FIX. The first waiter gets its answer instead of timing out. + let out = outcome(&pool, ALL_PROVIDERS, first) + .await + .unwrap() + .expect("the waiter that asked for this sync must receive its outcome"); + assert_eq!(out.synced_count, Some(3)); + // Half two - the ORIGINAL PROTECTION, preserved. The later request was not + // serviced by work that started before it existed. assert!( - outcome(&pool, ALL_PROVIDERS).await.unwrap().is_none(), - "the older sync's outcome must NOT mark the new request complete - \ + outcome(&pool, ALL_PROVIDERS, second) + .await + .unwrap() + .is_none(), + "a request made mid-sync must NOT be satisfied by the sync already running - \ that would report success for a sync that never ran" ); + // ...and it is still serviceable, with its escalation intact. let req = claim(&pool, ALL_PROVIDERS) .await .unwrap() .expect("the mid-sync request must still be pending"); assert_eq!(req.mode, SyncMode::Force); assert_eq!(req.reason, "sync_now"); + assert_eq!(req.seq, second); +} + +/// Two waiters, one sync: the whole point of a watermark. Both producers asked +/// before anything was serviced, so one sync must satisfy both rather than each +/// needing its own provider round trip. +#[tokio::test] +async fn one_sync_satisfies_every_waiter_that_asked_before_it_ran() { + let pool = db().await; + let a = request(&pool, ALL_PROVIDERS, SyncMode::Force, "oauth_connected") + .await + .unwrap(); + let b = request(&pool, ALL_PROVIDERS, SyncMode::Force, "token_connected") + .await + .unwrap(); + + let claimed = claim(&pool, ALL_PROVIDERS).await.unwrap().expect("pending"); + complete(&pool, ALL_PROVIDERS, claimed.seq, Some(11), None) + .await + .unwrap(); + + for (label, seq) in [("first", a), ("second", b)] { + assert!( + outcome(&pool, ALL_PROVIDERS, seq).await.unwrap().is_some(), + "the {label} waiter must be satisfied by the single sync that covered it" + ); + } + assert!( + claim(&pool, ALL_PROVIDERS).await.unwrap().is_none(), + "coalesced requests must not leave extra work behind - that is a second \ + provider round trip for one user action" + ); +} + +/// `has_pending` is the gate that keeps an IDLE daemon from writing at all. +/// +/// The watcher ticks every 2 s forever and used to call `claim` unconditionally - an +/// `UPDATE`, so SQLite opened a write transaction and took a lock even when it +/// matched nothing. That was ~43,000 write transactions a day on an idle machine, +/// against a file a second process also writes, and it made every daemon kill far +/// likelier to land mid-write. +/// +/// So the states where the answer must be `false` matter more than the one where it +/// is `true`: each is a tick that now touches no lock at all. +#[tokio::test] +async fn has_pending_is_false_in_every_idle_state() { + let pool = db().await; + + assert!( + !has_pending(&pool, ALL_PROVIDERS).await.unwrap(), + "a fresh install with no row must not provoke a claim - this is the state \ + almost every tick runs in" + ); + + request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") + .await + .unwrap(); + assert!( + has_pending(&pool, ALL_PROVIDERS).await.unwrap(), + "real work must still be seen" + ); + + let claimed = claim(&pool, ALL_PROVIDERS).await.unwrap().expect("pending"); + assert!( + !has_pending(&pool, ALL_PROVIDERS).await.unwrap(), + "an in-flight request is not claimable, so ticks during a long sync must be \ + free too" + ); + + complete(&pool, ALL_PROVIDERS, claimed.seq, Some(2), None) + .await + .unwrap(); + assert!( + !has_pending(&pool, ALL_PROVIDERS).await.unwrap(), + "a completed row is the steady state after any sync - it must never read as \ + work, or the daemon would re-sync forever" + ); + + request(&pool, ALL_PROVIDERS, SyncMode::Gated, "plan_or_picker") + .await + .unwrap(); + assert!( + has_pending(&pool, ALL_PROVIDERS).await.unwrap(), + "a new request after a completion must be visible again" + ); +} + +/// A duplicate or out-of-order completion must never move the watermark backwards, +/// so a retrying consumer cannot un-answer a waiter that was already satisfied. +#[tokio::test] +async fn the_completion_watermark_only_moves_forward() { + let pool = db().await; + request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") + .await + .unwrap(); + let first = claim(&pool, ALL_PROVIDERS).await.unwrap().expect("pending"); + complete(&pool, ALL_PROVIDERS, first.seq, Some(5), None) + .await + .unwrap(); + + let second = request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") + .await + .unwrap(); + let claimed = claim(&pool, ALL_PROVIDERS).await.unwrap().expect("pending"); + complete(&pool, ALL_PROVIDERS, claimed.seq, Some(9), None) + .await + .unwrap(); + + // A late duplicate for the OLD seq arrives (a retry, a doubled tick). + complete(&pool, ALL_PROVIDERS, first.seq, Some(1), Some("stale")) + .await + .unwrap(); + + let out = outcome(&pool, ALL_PROVIDERS, second) + .await + .unwrap() + .expect("the newer waiter must stay satisfied"); + assert_eq!( + out.synced_count, + Some(9), + "the stale retry overwrote the result" + ); + assert_eq!(out.error, None, "the stale retry resurrected an old error"); } diff --git a/src/intelligence/sync_delegate.rs b/src/intelligence/sync_delegate.rs index 8475390f9..f2ddc7cce 100644 --- a/src/intelligence/sync_delegate.rs +++ b/src/intelligence/sync_delegate.rs @@ -152,29 +152,45 @@ async fn request_and_wait( label: &str, wait: Option, ) -> Delegation { - if let Err(e) = pm_sync_requests::request(pool, ALL_PROVIDERS, mode, label).await { - return Delegation::Failed { - error: format!( - "could not queue the sync request: {}", - crate::errors::chain(&e) - ), - }; - } - tracing::debug!(label, "pm sync requested - the daemon owns tracker auth"); + let seq = match pm_sync_requests::request(pool, ALL_PROVIDERS, mode, label).await { + Ok(seq) => seq, + Err(e) => { + return Delegation::Failed { + error: format!( + "could not queue the sync request: {}", + crate::errors::chain(&e) + ), + }; + } + }; + tracing::debug!( + label, + seq, + "pm sync requested - the daemon owns tracker auth" + ); match wait { - Some(budget) => wait_for_outcome(pool, label, budget).await, + Some(budget) => wait_for_outcome(pool, label, seq, budget).await, None => Delegation::Pending, } } /// Poll the request row until the daemon records an outcome, or `budget` elapses. /// -/// The row is keyed on the provider (`'*'`), so a concurrent CLI's request can reset it -/// and this can end up reading a sibling's outcome. That is fine: both asked for the same -/// thing, and "some sync just completed" is exactly what the caller needs to know. It -/// cannot read a *stale* outcome, because `request` clears `completed_at`. -async fn wait_for_outcome(pool: &SqlitePool, label: &str, budget: Duration) -> Delegation { +/// The row is keyed on the provider (`'*'`), so a concurrent producer's request shares +/// it. `seq` is what keeps that safe: it is the sequence number THIS caller's request +/// was given, and `outcome` only answers once the completion watermark reaches it. So a +/// sibling's sync can satisfy this caller (both asked for the same thing, and "a sync +/// finished after you asked" is exactly what the caller needs to know), while a sync that +/// finished BEFORE this request cannot - which is the stale read the previous +/// `completed_at`-clearing design tried to prevent by destroying the other waiter's +/// answer. +async fn wait_for_outcome( + pool: &SqlitePool, + label: &str, + seq: i64, + budget: Duration, +) -> Delegation { let deadline = std::time::Instant::now() + budget; loop { if std::time::Instant::now() >= deadline { @@ -186,7 +202,7 @@ async fn wait_for_outcome(pool: &SqlitePool, label: &str, budget: Duration) -> D return Delegation::Pending; } tokio::time::sleep(POLL_INTERVAL).await; - match pm_sync_requests::outcome(pool, ALL_PROVIDERS).await { + match pm_sync_requests::outcome(pool, ALL_PROVIDERS, seq).await { Ok(Some(out)) => { return match out.error { Some(error) => Delegation::Failed { error }, @@ -236,7 +252,7 @@ mod tests { assert_eq!(got, Delegation::Synced { count: None }); assert!( - pm_sync_requests::outcome(&pool, ALL_PROVIDERS) + pm_sync_requests::outcome(&pool, ALL_PROVIDERS, 1) .await .unwrap() .is_none(), @@ -289,13 +305,19 @@ mod tests { .await .unwrap(); pm_sync_requests::claim(&pool, ALL_PROVIDERS).await.unwrap(); - pm_sync_requests::complete(&pool, ALL_PROVIDERS, None, Some("refresh_token is invalid")) - .await - .unwrap(); + pm_sync_requests::complete( + &pool, + ALL_PROVIDERS, + 1, + None, + Some("refresh_token is invalid"), + ) + .await + .unwrap(); // Re-requesting clears the outcome, so the waiter must be the one to observe it: // drive the wait directly against the already-completed row. - let got = wait_for_outcome(&pool, "tasks-sync", Duration::from_secs(5)).await; + let got = wait_for_outcome(&pool, "tasks-sync", 1, Duration::from_secs(5)).await; assert_eq!( got, diff --git a/src/intelligence/sync_requests.rs b/src/intelligence/sync_requests.rs index 8e1838909..a60ea29c5 100644 --- a/src/intelligence/sync_requests.rs +++ b/src/intelligence/sync_requests.rs @@ -100,9 +100,17 @@ pub async fn run_watcher(pool: SqlitePool, mut shutdown_rx: watch::Receiver req, - Ok(None) => return, + // READ first, and claim only if there is something to claim. + // + // `claim` is an UPDATE, and SQLite opens a write transaction to evaluate one even + // when it matches nothing - so calling it unconditionally on this 2 s tick put + // ~43,000 write transactions a day on an idle machine's database, and made every + // daemon kill far likelier to land mid-write. See + // `pm_sync_requests::has_pending` for the full reasoning. The read is advisory; + // `claim` below is still what decides, so exclusivity is unchanged. + match pm_sync_requests::has_pending(pool, ALL_PROVIDERS).await { + Ok(false) => return, + Ok(true) => {} Err(e) => { // Logged at debug, not warn: on a fresh install this fires every 2 s // until migration 082 has run, and a warn-level line every 2 s would @@ -114,6 +122,20 @@ async fn service_once(pool: &SqlitePool) { ); return; } + } + + let req = match pm_sync_requests::claim(pool, ALL_PROVIDERS).await { + Ok(Some(req)) => req, + // Lost the race to another claimant between the read and here - fine, and + // the reason `has_pending` is documented as advisory. + Ok(None) => return, + Err(e) => { + tracing::debug!( + error = %crate::errors::chain(&e), + "could not claim a PM sync request" + ); + return; + } }; // Config is read here rather than captured at spawn time so a settings change @@ -124,6 +146,7 @@ async fn service_once(pool: &SqlitePool) { tracing::info!( mode = req.mode.as_str(), reason = %req.reason, + seq = req.seq, "servicing PM sync request" ); @@ -157,7 +180,9 @@ async fn service_once(pool: &SqlitePool) { } }; - if let Err(e) = pm_sync_requests::complete(pool, &req.provider, count, error.as_deref()).await { + if let Err(e) = + pm_sync_requests::complete(pool, &req.provider, req.seq, count, error.as_deref()).await + { tracing::warn!( error = %crate::errors::chain(&e), "could not record the PM sync outcome - the producer will keep waiting" @@ -186,7 +211,7 @@ mod tests { async fn service_once_is_quiet_with_no_requests() { let pool = db().await; service_once(&pool).await; - assert!(pm_sync_requests::outcome(&pool, ALL_PROVIDERS) + assert!(pm_sync_requests::outcome(&pool, ALL_PROVIDERS, 1) .await .unwrap() .is_none()); @@ -204,7 +229,7 @@ mod tests { service_once(&pool).await; - let out = pm_sync_requests::outcome(&pool, ALL_PROVIDERS) + let out = pm_sync_requests::outcome(&pool, ALL_PROVIDERS, 1) .await .unwrap() .expect("the request must be completed, not left pending"); diff --git a/src/main.rs b/src/main.rs index 5867f0542..3615d1f1b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1435,13 +1435,21 @@ async fn main() -> Result<()> { // ever on a row a producer wrote, so every refresh still traces to a human // action, which is what keeps a refresh POST from being in flight when a // laptop lid closes. - { + // + // Its `JoinHandle` is KEPT, unlike the loops above, and awaited in the + // shutdown sequence before the WAL checkpoint. This task is the daemon's + // only writer outside the poll loop, and `service_once` deliberately runs a + // whole sync (network + `pm_tasks` writes, tens of seconds) without + // re-checking the shutdown flag, so dropping the handle meant checkpointing + // and closing the pool underneath live writes on every restart. See the + // shutdown site for the full reasoning. + let sync_watcher = { let pool_sync = meridian.clone(); let rx_sync = shutdown_rx.clone(); tokio::spawn(async move { meridian::intelligence::sync_requests::run_watcher(pool_sync, rx_sync).await; - }); - } + }) + }; // 8b. Poll loop — ETL, PM sync, and FM categorization on the configured interval. // Track the last-applied log level so we can detect changes and hot-reload @@ -1597,7 +1605,42 @@ async fn main() -> Result<()> { // 9. Shutdown tracing::info!("shutting down"); - meridian::platform::release_endpoint(); + + // 9a. Wait for the PM sync watcher to actually STOP before touching the WAL. + // + // `shutdown_tx.send(true)` above only sets a flag, and the watcher checks it + // on its sleep - not around `service_once`, which is deliberate (a claimed + // request finishes and records an outcome rather than being cut off with the + // row left claimed). The consequence is that after the signal this task can + // still be inside a full provider sync for tens of seconds, writing to + // `pm_tasks`. Without this await, `checkpoint_wal` and `close` below ran + // underneath those writes on EVERY restart - and a reconnect flow restarts + // the daemon, which is exactly when a burst of sync requests exists to + // service. A TRUNCATE checkpoint racing live writes is the one thing that + // function exists to prevent (see its doc: it hands the next generation a + // half-written WAL while the tray keeps a stale view of the file). + // + // Bounded, because the whole point of the no-interrupt design is that + // `service_once` may be mid-network-call: on timeout we proceed anyway and + // say so, which is strictly better than the old unconditional race, and no + // worse than a hard kill. + { + const WATCHER_DRAIN_TIMEOUT: Duration = Duration::from_secs(45); + match tokio::time::timeout(WATCHER_DRAIN_TIMEOUT, sync_watcher).await { + Ok(Ok(())) => tracing::debug!("PM sync watcher stopped cleanly before checkpoint"), + // `JoinError`'s Display ignores `f.alternate()`, so `chain()` would + // render byte-identically - it carries a panic payload or a + // cancellation, never a `.context()` chain. + Ok(Err(e)) => { + tracing::warn!(error = %e, "sync watcher ended abnormally"); // not-anyhow: JoinError + } + Err(_elapsed) => tracing::warn!( + timeout_s = WATCHER_DRAIN_TIMEOUT.as_secs() as i64, + "PM sync watcher did not stop in time - checkpointing anyway, which may leave a non-empty WAL" + ), + } + } + // See `db::meridian::checkpoint_wal`'s doc for why this runs before every // close, not just a plain shutdown. Best-effort: a failed checkpoint must // not block shutdown. @@ -1606,6 +1649,19 @@ async fn main() -> Result<()> { } meridian.close().await; + // 9b. Release the single-instance endpoint LAST, after the pool is closed. + // + // This used to run first, before the checkpoint. `daemon_already_running` is + // how a starting daemon decides whether to bow out (4a-ter), so releasing it + // early opens the guard while THIS process is still checkpointing and closing + // - the window `single_instance_check_precedes_setup_db_and_bind_follows_it` + // exists to close, reopened from the exiting side. It only pinned the + // STARTING daemon's ordering. Under launchd the relaunch is immediate, and a + // new generation running migrations against a file the old one is mid- + // checkpoint on is the double-writer profile the fleet-correlated corruption + // was traced to. + meridian::platform::release_endpoint(); + // Flush OTel exporters FIRST, while the runtime is alive — this writes the // daemon's final shutdown spans/logs into the spool's pending/ dir... obs_guard.shutdown().await; @@ -1820,4 +1876,58 @@ mod startup_order_tests { bind at byte {bind_pos}." ); } + + /// The EXIT-side ordering, which the test above does not cover and which was + /// wrong until 1.91.0-staging.2's write wedge was traced. + /// + /// Three things must happen in this order on shutdown: + /// 1. await the PM sync watcher — it is the daemon's only writer outside the + /// poll loop, and `service_once` deliberately ignores the shutdown flag + /// once it has claimed a row (so it can finish and record an outcome), so + /// it can still be writing for tens of seconds after the signal; + /// 2. `checkpoint_wal` — a TRUNCATE checkpoint racing those live writes is + /// the exact thing that function exists to prevent, and it is what hands + /// the next daemon generation a half-written WAL while the tray keeps a + /// stale view of the file; + /// 3. `release_endpoint` LAST — it is what `daemon_already_running` answers, + /// so releasing it before the checkpoint and close lets a relaunching + /// daemon pass the single-instance guard and start migrating against a + /// file this process is still checkpointing. Under launchd the relaunch + /// is immediate, so that window is real, not theoretical. + /// + /// Same self-scanning idiom (and the same truncate-at-the-test-module trap) + /// as the test above. + #[test] + fn shutdown_awaits_the_sync_watcher_then_checkpoints_then_releases_the_endpoint() { + const SRC: &str = include_str!("main.rs"); + let prod = SRC + .split_once("\n#[cfg(test)]") + .map_or(SRC, |(before, _)| before); + + let await_pos = prod + .find("WATCHER_DRAIN_TIMEOUT, sync_watcher)") + .expect("shutdown must await the PM sync watcher's JoinHandle"); + let checkpoint_pos = prod + .find("meridian::db::meridian::checkpoint_wal(&meridian).await") + .expect("shutdown must checkpoint the WAL"); + let release_pos = prod + .find("meridian::platform::release_endpoint();") + .expect("shutdown must release the single-instance endpoint"); + + assert!( + await_pos < checkpoint_pos, + "the PM sync watcher must be awaited BEFORE the WAL checkpoint — \ + checkpointing underneath its live writes is what corrupted the \ + shared WAL index. Found await at byte {await_pos}, checkpoint at \ + byte {checkpoint_pos}." + ); + assert!( + checkpoint_pos < release_pos, + "release_endpoint() must run AFTER the checkpoint and pool close — \ + releasing it earlier opens the single-instance guard while this \ + process is still writing, letting a relaunching daemon migrate \ + against the same file. Found checkpoint at byte {checkpoint_pos}, \ + release at byte {release_pos}." + ); + } } diff --git a/src/migrations/083_pm_sync_request_seq.sql b/src/migrations/083_pm_sync_request_seq.sql new file mode 100644 index 000000000..5c3cc7e76 --- /dev/null +++ b/src/migrations/083_pm_sync_request_seq.sql @@ -0,0 +1,35 @@ +-- ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity + +-- Give each PM sync request a monotonic sequence number, so a producer can tell +-- WHICH request an outcome belongs to. +-- +-- Migration 082 modelled the outbox as one row per provider whose completion was +-- signalled by `completed_at` going non-NULL, and `request()` cleared it so the row +-- "unambiguously represents work still to do". With one waiter that is fine. With +-- two it loses answers, and the tracker-connect flow always produces at least two: +-- `oauth_connected`, `token_connected` and the user's own "Sync now" all fire inside +-- a few seconds. +-- +-- Measured on 1.91.0-staging.2: request A is claimed and a real Jira sync starts; +-- request B lands mid-flight and nulls `claimed_at`/`completed_at`; the daemon +-- finishes and calls `complete()`, whose guard was `claimed_at IS NOT NULL` - now +-- NULL - so the outcome was DISCARDED and the sync re-run from scratch. Every waiter +-- then polled for its full 30 s budget and reported failure for a sync that had +-- actually succeeded, repeatedly. +-- +-- With a sequence, "done" is a watermark rather than a flag: a producer holding seq +-- N is satisfied by any `completed_seq >= N`, so overlapping requests coalesce +-- instead of cannibalising each other, and no completion can be misattributed to a +-- request that was never serviced. +-- +-- `seq` starts at 1 and `completed_seq` is NULL-means-nothing-completed, so +-- "pending" is `seq > COALESCE(completed_seq, 0)` everywhere. +ALTER TABLE pm_sync_requests ADD COLUMN seq INTEGER NOT NULL DEFAULT 1; +ALTER TABLE pm_sync_requests ADD COLUMN completed_seq INTEGER; + +-- Carry the existing row's state across rather than resetting it. A row already +-- completed under 082 must NOT read as pending after this migration (that would fire +-- a spurious provider sync on the first daemon start after an update, for every +-- installed user at once); a row mid-flight must stay pending so it is still +-- serviced. +UPDATE pm_sync_requests SET completed_seq = seq WHERE completed_at IS NOT NULL; diff --git a/tray/src-tauri/src/commands/daemon.rs b/tray/src-tauri/src/commands/daemon.rs index 83afe313b..fef8680f5 100644 --- a/tray/src-tauri/src/commands/daemon.rs +++ b/tray/src-tauri/src/commands/daemon.rs @@ -248,6 +248,14 @@ pub(crate) async fn reload_daemon_with( /// point in time this function does not need to reason about. async fn reload_with_pool_cycle(pool: &crate::db_pool::DbPool) -> Result { with_reload_lock(|| async { + // Also excludes a concurrent `DbPool::recover_if_corrupt`, which closes and + // reopens this same handle. `with_reload_lock` alone only serialises reloads + // against each other, so without this a recycle could REOPEN the pool in the + // window between the close below and the restart signal - leaving a live + // connection spanning two daemon generations, which is the 2026-08-24 + // corruption profile this whole close/reopen dance exists to prevent. See + // `DbPool::lock_cycle`. + let _cycle = pool.lock_cycle().await; pool.close().await; let result = super::daemon_control::reload().await; pool.reopen().await; @@ -901,4 +909,44 @@ mod tests { "a later reload still reaches the daemon" ); } + + /// Every close/reopen of the tray's pool must hold `DbPool::lock_cycle`, and + /// `reload_with_pool_cycle` must take it BEFORE it closes. + /// + /// `with_reload_lock` only serialises reloads against each other. + /// `DbPool::recover_if_corrupt` is a second close/reopen site, so without a + /// shared lock the two interleave: + /// + /// 1. reload closes - the handle is `None`; + /// 2. a recycle sees `None`, treats it as nothing to close, and REOPENS; + /// 3. reload signals the daemon restart, with that fresh pool open across it. + /// + /// Step 3 is the 2026-08-24 corruption profile - a tray connection spanning two + /// daemon generations - i.e. the self-heal would have reintroduced the very bug + /// this close/reopen dance exists to prevent. Source-scanned because reproducing + /// it needs a real launchd daemon restart racing a real corrupt write. + #[test] + fn the_reload_cycle_holds_the_shared_pool_lock_before_closing() { + const SRC: &str = include_str!("daemon.rs"); + let prod = SRC + .split_once("\n#[cfg(test)]") + .map_or(SRC, |(before, _)| before); + let body = prod + .split_once("async fn reload_with_pool_cycle") + .expect("reload_with_pool_cycle must exist") + .1; + + let lock_pos = body + .find("lock_cycle().await") + .expect("reload_with_pool_cycle must acquire DbPool::lock_cycle"); + let close_pos = body + .find("pool.close().await") + .expect("reload_with_pool_cycle must close the pool"); + assert!( + lock_pos < close_pos, + "the shared cycle lock must be held BEFORE the close - taking it after \ + leaves the window where a concurrent recycle can reopen the pool into \ + the daemon restart. lock at {lock_pos}, close at {close_pos}." + ); + } } diff --git a/tray/src-tauri/src/commands/integrations.rs b/tray/src-tauri/src/commands/integrations.rs index 2e6e1d502..0d10f792c 100644 --- a/tray/src-tauri/src/commands/integrations.rs +++ b/tray/src-tauri/src/commands/integrations.rs @@ -684,7 +684,10 @@ pub async fn save_integration_token( // First-time (or credential-change) connect — force a sync so the board // populates immediately rather than waiting for the next on-demand trigger. // There's no stale cache to protect here, so gating buys nothing. - crate::commands::tasks::trigger_background_pm_force_sync(db_pool.get(), "token_connected"); + crate::commands::tasks::trigger_background_pm_force_sync( + db_pool.inner().clone(), + "token_connected", + ); Ok(serde_json::json!({ "ok": true, "reloaded": reloaded })) } @@ -1203,7 +1206,7 @@ pub async fn start_oauth( db_pool: State<'_, crate::db_pool::DbPool>, ) -> Result { match body.provider.as_str() { - "jira" | "trello" => start_oauth_in_process(body.provider, db_pool.get()), + "jira" | "trello" => start_oauth_in_process(body.provider, db_pool.inner().clone()), "github" => start_oauth_github_device(body.provider, db_pool.inner().clone()).await, other => Err(format!("Unknown provider: {other}")), } @@ -1258,7 +1261,7 @@ pub async fn cancel_oauth(body: CancelOAuthBody) -> Result<(), String> { /// [`AtomicBool`] prevents two flows from racing to bind the same loopback port. fn start_oauth_in_process( provider: String, - db: Option, + db: crate::db_pool::DbPool, ) -> Result { // Resolve credentials from .env WITHOUT mutating process env. let mode = crate::install::detect_install_mode(); @@ -1470,9 +1473,12 @@ async fn start_oauth_github_device( return; } tracing::info!("GitHub device-flow login succeeded"); - // Grab the DB handle BEFORE `db_pool` is moved into the reload below, - // so the sync request can still be written afterwards. - let sync_db = db_pool.get(); + // Clone the HANDLE (not `db_pool.get()`) before `db_pool` is moved + // into the reload below. The old code took a pool here, which the + // reload's `DbPool::close` then killed - so the sync request it was + // saved for could never be written. The handle survives, and + // `request_sync` resolves it after the reopen. + let sync_db = db_pool.clone(); // Best-effort reload so the token takes effect now, not next restart. if let Err(e) = crate::commands::daemon::reload_daemon_with(db_pool).await { tracing::debug!(error = %e, "daemon reload after GitHub connect (non-fatal)"); diff --git a/tray/src-tauri/src/commands/pause.rs b/tray/src-tauri/src/commands/pause.rs index 7d81ccc17..7a01d12e4 100644 --- a/tray/src-tauri/src/commands/pause.rs +++ b/tray/src-tauri/src/commands/pause.rs @@ -338,9 +338,13 @@ pub(crate) async fn resume_capture( } } - // Restart the capture engine so screen recording resumes. + // Restart the capture engine so screen recording resumes. Resolve the managed + // HANDLE rather than passing `pool` (a snapshot this fn was handed): the capture + // consumers outlive any single pool generation - see `start_capture`'s doc. #[cfg(feature = "capture")] - crate::start_capture(state.clone(), pool.cloned()); + if let Some(db) = crate::db_pool::from_app(app) { + crate::start_capture(state.clone(), db); + } // Emit immediately so the popover reverts to the picker without waiting for the next tick. if let Ok(s) = state.lock() { diff --git a/tray/src-tauri/src/commands/tasks.rs b/tray/src-tauri/src/commands/tasks/mod.rs similarity index 54% rename from tray/src-tauri/src/commands/tasks.rs rename to tray/src-tauri/src/commands/tasks/mod.rs index f626c7bae..76a429d71 100644 --- a/tray/src-tauri/src/commands/tasks.rs +++ b/tray/src-tauri/src/commands/tasks/mod.rs @@ -81,14 +81,44 @@ pub struct SyncResult { /// tighter — a faster poll would just burn reads without seeing a result any sooner. const OUTCOME_POLL_INTERVAL: Duration = Duration::from_millis(500); -/// Turn a failed request-write into something a user can act on. +/// Shown when an outbox query fails because `meridian.db` itself is damaged. Points +/// at the banner rather than repeating the SQLite text, because +/// [`explain_outbox_failure`] has just raised that banner and it carries both the +/// full cause and a Repair button - a settings panel has neither. +const DB_DAMAGED_MESSAGE: &str = + "Meridian's database is damaged - use the Repair Database banner on the dashboard"; + +/// Shown during the update window described on [`explain_outbox_failure`]. +const UPDATE_IN_PROGRESS_MESSAGE: &str = + "Meridian is still finishing an update - try again in a moment"; + +/// What a failed `pm_sync_requests` query means for the user, plus the side effect it +/// must trigger first. +/// +/// Both halves of the handoff - the request write and the outcome read - fail for the +/// same three reasons, so they classify here instead of each growing its own ladder. +/// Callers render and log the chain themselves via [`crate::cmd_err!`] and pass the +/// result in as `rendered`, which keeps each site's log message a constant (better +/// grouping in OpenObserve than one message with the operation interpolated into it). +/// +/// # Corruption must reach the banner, not a settings panel +/// +/// This is the branch the whole function exists for. A staging machine's +/// `meridian.db` had real b-tree damage and these two queries were the ONLY code on +/// it to find out: `repair_boot`'s startup probe is skipped while a daemon answers, +/// the daemon latches only when its own queries reach a damaged page, and +/// `poll::refresh` covers only its four dashboard reads. So the user was shown +/// `could not queue the sync: ... database disk image is malformed` inside Settings, +/// with no banner and no Repair button - a recoverable fault presented as a failed +/// button press. [`crate::db_pool::raise_if_corrupt`] fixes that at the source; this +/// function just has to call it and then say something better than the SQL. /// /// # Why the missing-table case is special-cased /// /// `pm_sync_requests` arrives in migration 082, and **only the daemon runs migrations** /// (the tray opens the file with `create_if_missing(false)` and assumes the daemon made -/// it). So during an app update there is a window — new tray already running, daemon not -/// yet restarted onto the new binary — where the table genuinely does not exist yet. +/// it). So during an app update there is a window - new tray already running, daemon not +/// yet restarted onto the new binary - where the table genuinely does not exist yet. /// /// It is seconds long and self-heals the moment the daemon restarts, but a user who /// presses "Sync now" inside it would otherwise be shown a raw SQL string: @@ -100,24 +130,67 @@ const OUTCOME_POLL_INTERVAL: Duration = Duration::from_millis(500); /// `Database` error whose only distinguishing feature IS its text; the match is /// deliberately loose (table name plus "no such table") so a reworded sqlite message /// degrades to the generic branch rather than mis-reporting something else. -fn queue_failure_message(e: &anyhow::Error) -> String { - let detail = format!("{e:#}"); - if detail.contains("no such table") && detail.contains("pm_sync_requests") { - tracing::warn!("pm_sync_requests missing - the daemon has not applied migration 082 yet"); - return "Meridian is still finishing an update - try again in a moment".to_string(); +/// Corruption, by contrast, is classified by `is_corrupt_error` on the real error - +/// never by string matching - so it stays correct across sqlite wordings. +async fn explain_outbox_failure( + db: &SqlitePool, + e: &anyhow::Error, + rendered: &str, + fallback: &str, +) -> String { + crate::db_pool::raise_if_corrupt(db, e).await; + + if meridian::db::integrity::is_corrupt_error(e) { + return DB_DAMAGED_MESSAGE.to_string(); + } + // "no such table" (082 not applied) OR "no such column" (083 not applied). The + // column case is not hypothetical: migration 083 added `seq`/`completed_seq`, and + // during an update the new tray queries them before the daemon has migrated. The + // first version of this branch only matched the table and would have shown every + // updating user `no such column: seq` - the exact raw-SQL-in-a-settings-panel + // failure it was written to prevent, one migration later. Any future migration + // touching this table inherits the same window, which is why this matches the + // schema-mismatch FAMILY rather than one message. + // SQLite names the TABLE for a missing table (`no such table: + // pm_sync_requests`) but only the COLUMN for a missing column (`no such column: + // seq`) - and none of this module's `.context(...)` strings contain the literal + // table name, so the two cases need separate needles rather than one + // table-plus-kind check. + // + // Matched on the full `no such column: ` prefix, not on the bare column + // name: `rendered.contains("seq")` would fire on any message containing + // "sequence" or "consequently" and mis-report an unrelated fault as a pending + // update, which sends the user to wait out an update that already finished. + const SCHEMA_PENDING: [&str; 3] = [ + "no such table: pm_sync_requests", + "no such column: seq", + "no such column: completed_seq", + ]; + if SCHEMA_PENDING + .iter() + .any(|needle| rendered.contains(needle)) + { + return UPDATE_IN_PROGRESS_MESSAGE.to_string(); } - tracing::warn!(error = %detail, "could not queue a PM sync request"); - format!("could not queue the sync: {detail}") + format!("{fallback}: {rendered}") } -/// Resolve the tray's DB handle, or an error string suitable for returning straight -/// to the frontend. `None` means the pool is closed (a repair or a corrupt DB), which -/// is a real condition rather than a bug — say so plainly instead of unwrapping. +/// Confirm a pool is currently open, without keeping the one we looked at. +/// +/// Returns the HANDLE, not a `SqlitePool`. It used to return the pool, which every +/// caller then held for the length of a 30 s poll loop - so a recycle or a daemon +/// reload part-way through left the rest of that loop querying a dead pool. Callers +/// resolve `get()` per use instead; this only answers "is there any point starting". +/// +/// `None` means the pool is closed (a repair, or a recycle in progress), which is a +/// real condition rather than a bug - say so plainly instead of unwrapping. fn require_pool( pool: &tauri::State<'_, crate::db_pool::DbPool>, -) -> Result { - pool.get() - .ok_or_else(|| "the database is not open - Meridian may be repairing it".to_string()) +) -> Result { + if pool.get().is_none() { + return Err("the database is not open - Meridian may be repairing it".to_string()); + } + Ok(pool.inner().clone()) } /// Re-sync the board from the tracker (the ported /api/tasks/sync POST) — always @@ -215,9 +288,14 @@ pub async fn request_gated_sync_tasks( /// writer to race for the rotating credential - the tray still never holds a token /// itself. Without it, a queued row would sit unserviced and the user would watch a /// spinner time out with the daemon stopped. +/// Takes the `DbPool` HANDLE and resolves it per query, rather than holding one +/// `SqlitePool` for the whole 30 s wait. That matters here specifically: a daemon +/// reload or a `recover_if_corrupt` recycle part-way through the poll loop replaces +/// the pool, and a cached one would spend the rest of the budget querying a closed +/// handle and then report a timeout for a sync that had finished. #[tracing::instrument(skip(db), fields(mode = mode.as_str()))] async fn ask_daemon_to_sync( - db: &SqlitePool, + db: &crate::db_pool::DbPool, mode: SyncMode, reason: &'static str, fallback_cli: &'static str, @@ -236,22 +314,65 @@ async fn ask_daemon_to_sync( })); } - if let Err(e) = pm_sync_requests::request(db, ALL_PROVIDERS, mode, reason).await { - return Err(queue_failure_message(&e)); - } + // The sequence number this request was given. Polling the outcome WITHOUT it is + // what made "Sync now" report failure for syncs that had succeeded: the connect + // flow writes several requests seconds apart, and the old row-level + // `completed_at` flag could not say which one an outcome belonged to. + // + // Attempted twice: a first failure whose cause is a broken pool VIEW (not damaged + // data) is recovered by `recover_if_corrupt` recycling the connections, and the + // retry then succeeds - so the user's button works instead of them having to + // discover that quitting and relaunching the app is the cure. Exactly two + // attempts: the recycle either fixed it or the fault is real, and a loop here + // would hold a user-facing command open against a database that cannot serve it. + let mut attempt = 0; + let seq = loop { + attempt += 1; + let pool = db + .get() + .ok_or_else(|| "the database is not open - Meridian may be repairing it".to_string())?; + match pm_sync_requests::request(&pool, ALL_PROVIDERS, mode, reason).await { + Ok(seq) => break seq, + Err(e) => { + let rendered = crate::cmd_err!(e, "could not queue a PM sync request"); + // Raises the banner and recycles the pool when the fault is a broken + // view; returns whether a retry is worth making. + let recovered = db.recover_if_corrupt(&e).await; + crate::db_pool::raise_if_corrupt(&pool, &e).await; + if recovered && attempt == 1 { + tracing::info!("retrying the PM sync request on the recycled pool"); + continue; + } + return Err(explain_outbox_failure( + &pool, + &e, + &rendered, + "could not queue the sync", + ) + .await); + } + } + }; let deadline = tokio::time::Instant::now() + SYNC_TIMEOUT; loop { if tokio::time::Instant::now() >= deadline { tracing::warn!( timeout_s = SYNC_TIMEOUT.as_secs() as i64, + seq, "daemon did not report a sync outcome in time" ); return Ok(None); } tokio::time::sleep(OUTCOME_POLL_INTERVAL).await; - match pm_sync_requests::outcome(db, ALL_PROVIDERS).await { + let Some(pool) = db.get() else { + // A recycle or a daemon reload has the pool closed right now. Keep + // waiting rather than failing - the request row is already written and + // the daemon will service it. + continue; + }; + match pm_sync_requests::outcome(&pool, ALL_PROVIDERS, seq).await { Ok(Some(out)) => { if let Some(err) = out.error { tracing::warn!(error = %err, "daemon reported a sync failure"); @@ -265,7 +386,30 @@ async fn ask_daemon_to_sync( return Ok(Some(SyncResult { ok: true, detail })); } Ok(None) => continue, - Err(e) => return Err(format!("could not read the sync outcome: {e}")), + // `cmd_err!`, never a bare `{e}`: every `pm_sync_requests` query adds its + // own `.context(...)`, and `anyhow`'s `Display` renders ONLY the outermost + // one. This site shipped as `could not read the sync outcome: reading the + // PM sync outcome` on a machine whose database was corrupt - the context + // twice over and the actual `(code: 11) database disk image is malformed` + // nowhere, which is precisely the 1.83.2 field incident `cmd_err!` was + // written for. + Err(e) => { + let rendered = crate::cmd_err!(e, "could not read the PM sync outcome"); + // Recycle on a broken view here too, then keep waiting rather than + // failing: the request row is written and the daemon is servicing it, + // so a healed pool on the next poll turn still reports a real result. + if db.recover_if_corrupt(&e).await { + tracing::info!("recycled the pool mid-wait - continuing to poll"); + continue; + } + return Err(explain_outbox_failure( + &pool, + &e, + &rendered, + "could not read the sync outcome", + ) + .await); + } } } } @@ -280,7 +424,7 @@ async fn ask_daemon_to_sync( /// is not evidence anyone is about to make a decision from the whole board. The two /// screens that genuinely are — the daily plan and the retarget ticket picker — ask /// for themselves through [`request_gated_sync_tasks`]. See that command's doc. -pub(crate) fn trigger_background_pm_force_sync(db: Option, reason: &'static str) { +pub(crate) fn trigger_background_pm_force_sync(db: crate::db_pool::DbPool, reason: &'static str) { request_sync(db, SyncMode::Force, reason); } @@ -291,74 +435,45 @@ pub(crate) fn trigger_background_pm_force_sync(db: Option, reason: & /// result, so a queued row that the next daemon start services is the right outcome - /// spawning a process per window open is exactly the cost this replaced. /// -/// Takes `Option` (i.e. `DbPool::get()`) rather than a pool or an -/// `AppHandle`, because `None` is a real state and not an error: the pool is closed -/// while a corrupt DB is being repaired. Callers pass what they already hold, which -/// is a `DbPool` in the integration paths and app state in the window paths. +/// Takes the [`crate::db_pool::DbPool`] HANDLE and resolves it **inside** the spawned +/// task, not at the call site. /// -/// Best-effort by design: these fire from window-open and connect-success paths -/// where a failure must never block the thing the user asked for, and the next -/// trigger (or their explicit "Sync now") retries anyway. -fn request_sync(db: Option, mode: SyncMode, reason: &'static str) { - let Some(db) = db else { - tracing::debug!(reason, "pm sync request skipped - database not open"); - return; - }; +/// This parameter used to be `Option`, and the reasoning was that `None` +/// is a real state (the pool is closed while a corrupt DB is repaired) so callers +/// should pass what they already hold. The state check was right; taking a pool to do +/// it was not. Every caller is a connect-success path, and those paths **restart the +/// daemon**, which calls `DbPool::close`. A pool resolved before that point is dead by +/// the time this task runs - `integrations.rs` even had a comment explaining that it +/// grabbed the pool early "so the sync request can still be written afterwards", +/// which is precisely backwards. Resolving after the spawn means the task sees +/// whichever generation is live when it actually writes. +/// +/// Best-effort by design: these fire from connect-success paths where a failure must +/// never block the thing the user asked for, and the next trigger (or their explicit +/// "Sync now") retries anyway. A corrupt database is the one exception worth +/// surfacing, so it still raises the banner. +fn request_sync(db: crate::db_pool::DbPool, mode: SyncMode, reason: &'static str) { tauri::async_runtime::spawn(async move { - match pm_sync_requests::request(&db, ALL_PROVIDERS, mode, reason).await { - Ok(()) => tracing::debug!(reason, mode = mode.as_str(), "pm sync requested"), - Err(e) => tracing::debug!(reason, error = %e, "pm sync request failed"), + let Some(pool) = db.get() else { + tracing::debug!(reason, "pm sync request skipped - database not open"); + return; + }; + match pm_sync_requests::request(&pool, ALL_PROVIDERS, mode, reason).await { + // Nothing waits on this one, so the sequence number is discarded. + Ok(_seq) => tracing::debug!(reason, mode = mode.as_str(), "pm sync requested"), + Err(e) => { + // Full chain: `anyhow`'s `Display` would render only + // `"writing a PM sync request"` and drop the SQLite code under it. + tracing::debug!( + reason, + error = %meridian::errors::chain(&e), + "pm sync request failed" + ); + crate::db_pool::raise_if_corrupt(&pool, &e).await; + } } }); } #[cfg(test)] -mod tests { - use super::*; - - /// The update window: `pm_sync_requests` does not exist yet because the daemon - /// has not applied migration 082. The user must see a transient-update message, - /// never a raw SQL string that reads like database damage. - #[test] - fn a_missing_requests_table_reads_as_a_pending_update() { - let e = anyhow::anyhow!( - "error returned from database: (code: 1) no such table: pm_sync_requests" - ); - - let msg = queue_failure_message(&e); - - assert_eq!( - msg, - "Meridian is still finishing an update - try again in a moment" - ); - assert!(!msg.contains("no such table"), "must not leak SQL: {msg}"); - } - - /// Any OTHER write failure keeps its detail. Collapsing every error into the - /// friendly update message would hide a real fault (a locked or corrupt DB) behind - /// "try again in a moment", which never resolves. - #[test] - fn other_failures_keep_their_detail() { - let e = anyhow::anyhow!("database is locked"); - - let msg = queue_failure_message(&e); - - assert!( - msg.contains("database is locked"), - "detail was dropped: {msg}" - ); - } - - /// A missing table that is NOT ours is somebody else's problem and must not be - /// reported as a pending update - that would send the user to wait out an update - /// that is already finished while the real fault goes unnamed. - #[test] - fn a_different_missing_table_is_not_reported_as_an_update() { - let e = anyhow::anyhow!("no such table: pm_tasks"); - - let msg = queue_failure_message(&e); - - assert!(msg.contains("pm_tasks"), "detail was dropped: {msg}"); - assert!(!msg.contains("finishing an update"), "misattributed: {msg}"); - } -} +mod tests; diff --git a/tray/src-tauri/src/commands/tasks/tests.rs b/tray/src-tauri/src/commands/tasks/tests.rs new file mode 100644 index 000000000..ba3000ace --- /dev/null +++ b/tray/src-tauri/src/commands/tasks/tests.rs @@ -0,0 +1,162 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +//! Unit tests for [`super`] — split out only to keep both files under the +//! repo's 500-line cap, following the same `{mod,tests}.rs` shape as +//! `meridian-core/src/pm_sync_requests/` and +//! `src/intelligence/providers/jira/`. + +use super::*; +async fn fresh_db() -> SqlitePool { + use sqlx::sqlite::SqliteConnectOptions; + use std::str::FromStr; + let opts = SqliteConnectOptions::from_str("sqlite::memory:") + .unwrap() + .create_if_missing(true); + let pool = SqlitePool::connect_with(opts).await.unwrap(); + sqlx::migrate!("../../src/migrations") + .run(&pool) + .await + .unwrap(); + pool +} + +/// Render an error the way the real call sites do, so the tests exercise the same +/// `rendered` string production sees rather than a hand-written one. +fn rendered(e: &anyhow::Error) -> String { + format!("{e:#}") +} + +async fn corrupt_notices(pool: &SqlitePool) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM system_notices WHERE notice_id = ?") + .bind(meridian::notices::DB_CORRUPT) + .fetch_one(pool) + .await + .unwrap() +} + +/// The update window: `pm_sync_requests` does not exist yet because the daemon +/// has not applied migration 082. The user must see a transient-update message, +/// never a raw SQL string that reads like database damage. +#[tokio::test] +async fn a_missing_requests_table_reads_as_a_pending_update() { + let pool = fresh_db().await; + let e = + anyhow::anyhow!("error returned from database: (code: 1) no such table: pm_sync_requests"); + + let msg = explain_outbox_failure(&pool, &e, &rendered(&e), "could not queue the sync").await; + + assert_eq!(msg, UPDATE_IN_PROGRESS_MESSAGE); + assert!(!msg.contains("no such table"), "must not leak SQL: {msg}"); + assert_eq!( + corrupt_notices(&pool).await, + 0, + "a pending migration is not corruption" + ); +} + +/// The regression this function was rewritten for. A corrupt database must (a) get +/// the `db.corrupt` banner raised, which is the only surface carrying a Repair +/// button, and (b) send the user there rather than printing SQLite's wording into +/// a settings panel that can do nothing about it. +#[tokio::test] +async fn corruption_raises_the_banner_and_points_at_it() { + let pool = fresh_db().await; + let e = anyhow::anyhow!( + "error returned from database: (code: 11) database disk image is malformed" + ) + .context("reading the PM sync outcome"); + + let msg = + explain_outbox_failure(&pool, &e, &rendered(&e), "could not read the sync outcome").await; + + assert_eq!(msg, DB_DAMAGED_MESSAGE); + assert_eq!( + corrupt_notices(&pool).await, + 1, + "corruption found by an outbox query must raise the same banner the daemon raises" + ); + assert!( + !msg.contains("malformed"), + "the banner carries the cause; the panel should not repeat it: {msg}" + ); +} + +/// The banner's detail must carry the real cause even though the panel message +/// does not - otherwise the diagnosis is lost exactly like the bare-`{e}` bug that +/// hid this incident in the first place. +#[tokio::test] +async fn the_banner_keeps_the_full_cause_chain() { + let pool = fresh_db().await; + let e = anyhow::anyhow!( + "error returned from database: (code: 11) database disk image is malformed" + ) + .context("reading the PM sync outcome"); + + explain_outbox_failure(&pool, &e, &rendered(&e), "could not read the sync outcome").await; + + let detail: String = + sqlx::query_scalar("SELECT detail FROM system_notices WHERE notice_id = ?") + .bind(meridian::notices::DB_CORRUPT) + .fetch_one(&pool) + .await + .unwrap(); + assert!( + detail.contains("database disk image is malformed"), + "banner dropped the cause: {detail}" + ); + assert!( + detail.contains("reading the PM sync outcome"), + "banner dropped the context: {detail}" + ); +} + +/// Any OTHER failure keeps its detail. Collapsing every error into a friendly +/// message would hide a real fault behind "try again in a moment", which never +/// resolves. It must also NOT raise the corruption banner - that would train the +/// user to run `db repair` for faults it cannot fix. +#[tokio::test] +async fn other_failures_keep_their_detail() { + let pool = fresh_db().await; + let e = anyhow::anyhow!("database is locked"); + + let msg = explain_outbox_failure(&pool, &e, &rendered(&e), "could not queue the sync").await; + + assert!( + msg.contains("database is locked"), + "detail was dropped: {msg}" + ); + assert!(msg.starts_with("could not queue the sync"), "{msg}"); + assert_eq!(corrupt_notices(&pool).await, 0, "a lock is not corruption"); +} + +/// A missing table that is NOT ours is somebody else's problem and must not be +/// reported as a pending update - that would send the user to wait out an update +/// that is already finished while the real fault goes unnamed. +#[tokio::test] +async fn a_different_missing_table_is_not_reported_as_an_update() { + let pool = fresh_db().await; + let e = anyhow::anyhow!("no such table: pm_tasks"); + + let msg = explain_outbox_failure(&pool, &e, &rendered(&e), "could not queue the sync").await; + + assert!(msg.contains("pm_tasks"), "detail was dropped: {msg}"); + assert!(!msg.contains("finishing an update"), "misattributed: {msg}"); +} + +/// The two call sites differ only in their fallback phrasing, and that phrasing is +/// what the user reads when nothing more specific applies. Pinned so a refactor +/// cannot silently make the outcome-read failure claim the write failed. +#[tokio::test] +async fn the_fallback_names_the_operation_that_actually_failed() { + let pool = fresh_db().await; + let e = anyhow::anyhow!("disk I/O error"); + + let read = + explain_outbox_failure(&pool, &e, &rendered(&e), "could not read the sync outcome").await; + let write = explain_outbox_failure(&pool, &e, &rendered(&e), "could not queue the sync").await; + + assert!( + read.starts_with("could not read the sync outcome"), + "{read}" + ); + assert!(write.starts_with("could not queue the sync"), "{write}"); +} diff --git a/tray/src-tauri/src/db_pool.rs b/tray/src-tauri/src/db_pool.rs deleted file mode 100644 index 3a2aef329..000000000 --- a/tray/src-tauri/src/db_pool.rs +++ /dev/null @@ -1,209 +0,0 @@ -//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity -//! The tray's swappable `meridian.db` pool handle. -//! -//! Before this module, `meridian.db`'s pool was opened once at tray startup -//! (`lib.rs`'s `open_existing_lazy` call) and handed to Tauri as bare -//! `Option>` managed state - held for the tray -//! process's ENTIRE lifetime, including across a daemon restart. -//! `commands::daemon::reload_daemon` SIGHUPs the daemon (macOS: exits and -//! relies on launchd, or in a dev session a human, to relaunch it) without the -//! tray's own connection ever knowing a restart happened - so the tray's pool -//! spans two different daemon process generations on the same file. That is -//! the confirmed trigger of a real `meridian.db` corruption incident -//! (2026-08-24): see PR #856, which fixed the daemon's shutdown to checkpoint -//! the WAL and made the tray's own reads detect corruption immediately - both -//! good independent hardening, but neither closes the actual gap. -//! -//! [`DbPool`] closes it: `reload_daemon` calls [`DbPool::close`] before -//! signaling and [`DbPool::reopen`] once the new daemon process is confirmed -//! up, so the tray never holds a connection spanning the boundary. Every -//! other call site is unaffected - [`DbPool::get`] returns the exact same -//! `Option` shape `State>>::inner()` used to, -//! just renamed, since "the pool might legitimately be absent right now" was -//! already part of every caller's contract (a `None` during a first launch, -//! before the daemon has created the file). -//! -//! # Who calls this -//! - `lib.rs`'s setup hook constructs it and calls `app.manage`. -//! - `commands::daemon::reload_daemon` calls `close`/`reopen` around the -//! signal - the one thing that could not be done through the old bare -//! `Option>` state. -//! - Every dashboard/poll read that used to do -//! `let Some(pool) = pool.inner() else { ... }` now does the same against -//! `pool.get()`. - -use meridian_core::SqlitePool; -use std::sync::{Arc, RwLock}; - -/// Swappable handle to the tray's `meridian.db` pool, managed as Tauri state -/// in place of a bare `Option>`. -/// -/// `uri`/`key_hex` are remembered at construction so [`reopen`](Self::reopen) -/// needs no arguments at its call site - `reload_daemon` has neither the DB -/// path nor the encryption key on hand, only this handle. -#[derive(Clone)] -pub struct DbPool { - inner: Arc>>, - uri: String, - key_hex: Option, -} - -/// Manual, not derived: several call sites take `DbPool` as a `#[tauri::command]` -/// parameter without `#[tracing::instrument(skip(...))]`, so a derived `Debug` -/// would print `key_hex` — the raw SQLCipher key — into a span every time one -/// of those commands runs. `key_hex.is_some()` is all any log ever needs. -impl std::fmt::Debug for DbPool { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("DbPool") - .field("uri", &self.uri) - .field("key_set", &self.key_hex.is_some()) - .finish() - } -} - -impl DbPool { - pub fn new(pool: Option, uri: String, key_hex: Option) -> Self { - Self { - inner: Arc::new(RwLock::new(pool)), - uri, - key_hex, - } - } - - /// The pool, if one is currently open - `None` before the daemon has - /// created `meridian.db` yet, or during the brief window between - /// [`close`](Self::close) and [`reopen`](Self::reopen) around a daemon - /// restart. Cheap: `sqlx::SqlitePool` is itself `Arc`-backed, so this is - /// a shallow clone, not a new connection. - pub fn get(&self) -> Option { - self.inner.read().unwrap_or_else(|e| e.into_inner()).clone() - } - - /// Close the pool and clear the handle. Called before signaling the - /// daemon to restart, so nothing on this side keeps a connection alive - /// spanning the old process's shutdown and the new one's startup - see - /// this module's header for the corruption this closes off. Every reader - /// sees `get() == None` for the duration and behaves exactly as it - /// already does on a cold start (empty defaults, no panic). - pub async fn close(&self) { - let taken = self.inner.write().unwrap_or_else(|e| e.into_inner()).take(); - if let Some(pool) = taken { - pool.close().await; - } - } - - /// Reopen against the same uri/key this handle was built with. Lazy, - /// matching the original startup open - see that call site's doc - /// (`lib.rs`) for why eager fails when `meridian.db` briefly does not - /// exist. Best-effort: a failure here is logged and leaves `get()` - /// returning `None`, same as any other reason the pool isn't open yet; - /// the daemon's own re-creation of the file on its next write heals it - /// exactly as a lazy pool always has. - pub async fn reopen(&self) { - match meridian_core::open_existing_lazy(&self.uri, self.key_hex.as_deref()).await { - Ok(pool) => { - *self.inner.write().unwrap_or_else(|e| e.into_inner()) = Some(pool); - } - Err(e) => { - tracing::error!( - error = %e, - "DbPool::reopen failed - meridian.db stays unavailable until the next reload or tray restart" - ); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - async fn migrated_db(dir: &std::path::Path) -> (String, meridian_core::SqlitePool) { - use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; - use std::str::FromStr; - - let path = dir.join("db_pool_test.db"); - let uri = format!("sqlite://{}", path.display()); - // `open_existing`/`open_existing_lazy` both set `create_if_missing(false)` - // (they assume the daemon already created the file) - a test fixture - // needs its own connect path to create one from scratch. - let opts = SqliteConnectOptions::from_str(&uri) - .unwrap() - .create_if_missing(true); - let pool = SqlitePoolOptions::new() - .connect_with(opts) - .await - .expect("create db"); - sqlx::migrate!("../../src/migrations") - .run(&pool) - .await - .expect("migrate"); - pool.close().await; - // Reopen through the same lazy path `DbPool` itself uses, so the - // handle under test behaves exactly like production. - let pool = meridian_core::open_existing_lazy(&uri, None) - .await - .expect("reopen lazily"); - (uri, pool) - } - - /// `get()` must return exactly what was passed to `new()`. - #[tokio::test] - async fn get_returns_the_pool_it_was_built_with() { - let dir = tempfile::tempdir().unwrap(); - let (uri, pool) = migrated_db(dir.path()).await; - let handle = DbPool::new(Some(pool), uri, None); - assert!(handle.get().is_some()); - } - - /// A handle built with no pool (e.g. the DB isn't open yet) must behave - /// exactly like the old `None` state every caller already handles. - #[tokio::test] - async fn get_is_none_when_built_empty() { - let handle = DbPool::new(None, "sqlite://does-not-matter".to_string(), None); - assert!(handle.get().is_none()); - } - - /// The exact sequence `reload_daemon` runs: close, then a read in - /// between must see `None` (nothing races the daemon's restart), then - /// reopen brings a working pool back without needing to be told the URI - /// again. - #[tokio::test] - async fn close_then_reopen_restores_a_working_pool() { - let dir = tempfile::tempdir().unwrap(); - let (uri, pool) = migrated_db(dir.path()).await; - let handle = DbPool::new(Some(pool), uri, None); - - handle.close().await; - assert!( - handle.get().is_none(), - "a reader between close() and reopen() must see no pool, not a stale one" - ); - - handle.reopen().await; - let reopened = handle.get().expect("reopen must restore a pool"); - // Prove it's a genuinely live connection, not just a non-None marker. - meridian_core::ping(&reopened) - .await - .expect("reopened pool must actually work"); - } - - /// `reopen` must not panic on failure, and must leave `get()` at `None` - /// rather than propagating the error - `reopen` is deliberately - /// best-effort (see its doc), the same shape as any other reason a lazy - /// pool isn't open yet. A missing/wrong-shaped file is NOT enough to - /// prove this (`open_existing_lazy` defers that check to first use, so - /// it would return `Ok` here regardless) - an invalid key is what - /// actually fails synchronously, at `validate_key_hex` inside - /// `open_existing_lazy` itself, before any connection is attempted. - #[tokio::test] - async fn reopen_failure_leaves_the_handle_empty_not_panicked() { - let handle = DbPool::new( - None, - "sqlite://does-not-matter".to_string(), - Some("not-valid-hex".to_string()), - ); - handle.reopen().await; - assert!(handle.get().is_none()); - } -} diff --git a/tray/src-tauri/src/db_pool/mod.rs b/tray/src-tauri/src/db_pool/mod.rs new file mode 100644 index 000000000..2b5f8a0c4 --- /dev/null +++ b/tray/src-tauri/src/db_pool/mod.rs @@ -0,0 +1,342 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +//! The tray's swappable `meridian.db` pool handle. +//! +//! Before this module, `meridian.db`'s pool was opened once at tray startup +//! (`lib.rs`'s `open_existing_lazy` call) and handed to Tauri as bare +//! `Option>` managed state - held for the tray +//! process's ENTIRE lifetime, including across a daemon restart. +//! `commands::daemon::reload_daemon` SIGHUPs the daemon (macOS: exits and +//! relies on launchd, or in a dev session a human, to relaunch it) without the +//! tray's own connection ever knowing a restart happened - so the tray's pool +//! spans two different daemon process generations on the same file. That is +//! the confirmed trigger of a real `meridian.db` corruption incident +//! (2026-08-24): see PR #856, which fixed the daemon's shutdown to checkpoint +//! the WAL and made the tray's own reads detect corruption immediately - both +//! good independent hardening, but neither closes the actual gap. +//! +//! [`DbPool`] closes it: `reload_daemon` calls [`DbPool::close`] before +//! signaling and [`DbPool::reopen`] once the new daemon process is confirmed +//! up, so the tray never holds a connection spanning the boundary. Every +//! other call site is unaffected - [`DbPool::get`] returns the exact same +//! `Option` shape `State>>::inner()` used to, +//! just renamed, since "the pool might legitimately be absent right now" was +//! already part of every caller's contract (a `None` during a first launch, +//! before the daemon has created the file). +//! +//! # Who calls this +//! - `lib.rs`'s setup hook constructs it and calls `app.manage`. +//! - `commands::daemon::reload_daemon` calls `close`/`reopen` around the +//! signal - the one thing that could not be done through the old bare +//! `Option>` state. +//! - Every dashboard/poll read that used to do +//! `let Some(pool) = pool.inner() else { ... }` now does the same against +//! `pool.get()`. +//! - Anything that touches this pool and can fail calls +//! [`raise_if_corrupt`] on the error - see that function's doc. + +use meridian_core::SqlitePool; +use std::sync::{Arc, RwLock}; +use std::time::Instant; + +/// The managed [`DbPool`] handle, from an app handle. +/// +/// For code that must reach the pool but is not a `#[tauri::command]` (so it +/// cannot take `State<'_, DbPool>`): the capture consumers, the poll loop's +/// guards, `resume_capture`. +/// +/// # Take the HANDLE, never a pool snapshot +/// +/// The point of returning `DbPool` rather than `Option` is that +/// callers must resolve [`DbPool::get`] **at each use**. A long-lived task that +/// clones the `SqlitePool` once and keeps it is the exact bug this exists to +/// prevent: [`close`](DbPool::close) can only reach the pool inside this +/// handle, so an escaped clone keeps its connections - and its WAL-index +/// (`-shm`) mapping - alive across the daemon restart the close/reopen dance +/// exists to fence off. Measured on 1.91.0-staging.2: the capture consumers +/// held such a clone for the whole process lifetime and wrote through it every +/// ~2.5 s, and after a reconnect-triggered daemon restart every write failed +/// with `(code: 11) database disk image is malformed` **while the file itself +/// was healthy** (`db check`: 40 tables clean) and reads kept succeeding. Only +/// writes break, because reads can still be served from the main file while the +/// WAL write path cannot. +pub(crate) fn from_app(app: &tauri::AppHandle) -> Option { + use tauri::Manager; + app.try_state::().map(|s| s.inner().clone()) +} + +/// If `err` indicates `meridian.db` is corrupt, raise the SAME `db.corrupt` +/// notice `main.rs`'s `etl_tick` raises on the daemon side - immediately, +/// from whichever side of the app noticed first. +/// +/// The daemon already had this covered for its own queries, but the tray holds +/// its own independent, long-lived pool on the same file (opened once at +/// startup, `lib.rs`'s `app.manage(db_pool)`) and touches different tables on +/// its own cadence. In the incident this was written for, the tray's poll-loop +/// reads hit `(code: 11) database disk image is malformed` a full 5+ minutes +/// before any daemon-side query happened to touch the same damage - and until +/// this function existed, that whole window was silent `tracing::warn!` noise +/// with no banner, because nothing on this side of the process ever called +/// `raise_typed`. Idempotent (`raise_typed` upserts), so calling it on every +/// failing tick is safe and cheap - it does not need its own latch the way the +/// daemon's ETL loop does, because a tick that keeps failing just keeps +/// refreshing the same notice row rather than retrying a query with side +/// effects. +/// +/// # Why this lives here and not in `poll::refresh` +/// +/// It started as a private helper wrapping that loop's four dashboard READS, +/// which quietly made "the tray noticed corruption" mean "one of four specific +/// reads noticed corruption". `commands::tasks`' PM-sync outbox writes are on +/// this same pool and outside all of it, so on a staging machine whose +/// `meridian.db` was damaged they were the only code to find the damage - and +/// reported it as a raw SQL string in a settings panel, with no banner and no +/// Repair button, because the three detectors that DO know what corruption +/// means each had a scope that excluded them: +/// +/// - `repair_boot`'s startup probe is skipped entirely while a daemon answers +/// (its own comment defers to "the notice banner instead"); +/// - the daemon latches only when ITS queries reach a damaged page; +/// - this helper only covered `poll::refresh`. +/// +/// Living on the pool module is what lets any of them call it - both `poll` and +/// `commands` already depend on this module for `DbPool` itself. +/// +/// # Coverage is still partial - do not read this as an invariant +/// +/// The rule this SHOULD enforce is "a failure on the tray's pool goes through +/// here". It does not yet. Wired today: `poll::refresh`'s four reads and +/// `commands::tasks`' two outbox queries. **Not** wired: `commands::dashboard`, +/// which has ~24 `cmd_err!` sites reading `pm_tasks`, triage, week and +/// coding-agent tables - so damage confined to those pages is still found +/// without raising the banner. +/// +/// That gap is narrower than it looks, because `poll::refresh` re-reads the +/// active-session/today/worklogs tables every ~30 s and the daemon latches on +/// its own ETL path, so most real damage is reached by something that does +/// raise. It is not zero, though, and the honest statement is that this is a +/// convention being adopted rather than one already held everywhere. Anything +/// added here should also be added to those sites rather than assuming they are +/// already covered. +pub(crate) async fn raise_if_corrupt(pool: &SqlitePool, err: &anyhow::Error) { + if !meridian::db::integrity::is_corrupt_error(err) { + return; + } + let _ = meridian::notices::raise_typed( + pool, + meridian::notices::Notice { + id: meridian::notices::DB_CORRUPT, + severity: "error", + title: "Meridian's database is damaged", + // Full chain, not `err.to_string()` - same reasoning as + // `crate::cmd_err!`'s doc comment: `anyhow::Error`'s `Display` + // renders only the outermost `.context()` and would otherwise + // drop the SQLite code a reader needs. + detail: &format!("{err:#}"), + remedy: Some("Quit Meridian, then run 'meridian db repair' in a terminal"), + event_key: meridian::notices::DB_CORRUPT, + deep_link: Some(meridian_core::notifications::deep_links::LOGS), + }, + ) + .await; +} + +/// Swappable handle to the tray's `meridian.db` pool, managed as Tauri state +/// in place of a bare `Option>`. +/// +/// `uri`/`key_hex` are remembered at construction so [`reopen`](Self::reopen) +/// needs no arguments at its call site - `reload_daemon` has neither the DB +/// path nor the encryption key on hand, only this handle. +#[derive(Clone)] +pub struct DbPool { + inner: Arc>>, + uri: String, + key_hex: Option, + /// Serialises [`recover_if_corrupt`](Self::recover_if_corrupt) and remembers + /// when it last ran, so concurrent failing writers recycle the pool once + /// between them instead of each racing their own close/reopen. + recycle: Arc>>, +} + +/// Minimum gap between two pool recycles. +/// +/// Without it, a wedged pool would recycle on EVERY failing write - the capture +/// consumers alone write every ~2.5 s - so a fault the recycle cannot fix (real +/// file damage, a revoked key) would turn into a close/reopen storm on the file +/// the daemon is also using. One attempt per window, then the banner and the +/// error stand. +const RECYCLE_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(30); + +/// Ceiling on the close half of a recycle. +/// +/// `SqlitePool::close` waits for checked-out connections to come back, and the +/// whole reason we are here is that something is wrong with this pool. Bounded so +/// a connection that never returns cannot hold the recycle lock - and therefore +/// every future recovery attempt - forever. `close` clears the handle +/// synchronously before it awaits, so a timeout still leaves `get()` at `None` +/// and the reopen below still installs a fresh pool. +const RECYCLE_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +/// Manual, not derived: several call sites take `DbPool` as a `#[tauri::command]` +/// parameter without `#[tracing::instrument(skip(...))]`, so a derived `Debug` +/// would print `key_hex` — the raw SQLCipher key — into a span every time one +/// of those commands runs. `key_hex.is_some()` is all any log ever needs. +impl std::fmt::Debug for DbPool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DbPool") + .field("uri", &self.uri) + .field("key_set", &self.key_hex.is_some()) + .finish() + } +} + +impl DbPool { + pub fn new(pool: Option, uri: String, key_hex: Option) -> Self { + Self { + inner: Arc::new(RwLock::new(pool)), + uri, + key_hex, + recycle: Arc::new(tokio::sync::Mutex::new(None)), + } + } + + /// Recover from a write that failed because this pool's view of the database is + /// broken: drop every connection and open a fresh pool. Returns whether the + /// caller now has a working pool and may retry. + /// + /// # Why the app must heal itself here + /// + /// On 1.91.0-staging.2 a reconnect-triggered daemon restart left the tray's + /// connections with a desynced WAL index (`-shm`). Reads kept working, every + /// write failed with `(code: 11) database disk image is malformed`, and `db + /// check` reported all 40 tables healthy - the data was never damaged, only this + /// process's bookkeeping. The only cure was quitting and relaunching the app, + /// which **no user has any way of knowing**. They just saw sync stop working. + /// + /// Closing and reopening the pool is what that relaunch did for the database + /// handle, so doing it here removes the need for the user to be told anything. + /// It is deliberately independent of *why* the view broke: the ordering fixes + /// that shipped alongside this close the mechanism we identified, but this is + /// what makes a mechanism we did NOT identify survivable rather than permanent. + /// + /// Non-corrupt errors return `false` immediately - a locked database or a + /// missing table must not trigger a reconnect. + /// + /// # Caller contract + /// + /// **Call this only after your query has returned**, never while holding a + /// connection from this pool. The close half waits for checked-out connections + /// to be returned, so a caller still holding one would wait on itself. Every + /// current caller is on an error path, where the connection is already back. + /// Exclusive access to this handle's close/reopen lifecycle. + /// + /// **Every close+reopen pair must hold this**, not just the recycle path. + /// `commands::daemon::reload_with_pool_cycle` had its own private lock, which + /// serialised reloads against each other but not against + /// [`recover_if_corrupt`](Self::recover_if_corrupt) - and interleaving those two + /// reintroduces the exact hazard the close/reopen dance exists to remove: + /// + /// 1. reload closes, handle is `None`; + /// 2. a recycle sees `None`, treats it as nothing to close, and REOPENS; + /// 3. reload then signals the daemon restart - with that fresh pool open + /// across it. + /// + /// Step 3 is the 2026-08-24 corruption profile (a tray connection spanning two + /// daemon generations with no WAL checkpoint between them). The lock lives on the + /// handle rather than in either caller so a third close/reopen site cannot be + /// added without one. + /// + /// The guard's value is the last recycle instant, which is also what makes the + /// cooldown check-and-set atomic. + pub(crate) async fn lock_cycle(&self) -> tokio::sync::MutexGuard<'_, Option> { + self.recycle.lock().await + } + + pub(crate) async fn recover_if_corrupt(&self, err: &anyhow::Error) -> bool { + if !meridian::db::integrity::is_corrupt_error(err) { + return false; + } + + // Held across the close/reopen on purpose: it serialises concurrent + // recyclers, excludes a `reload_daemon` cycle (see `lock_cycle`), and makes + // the cooldown check-and-set atomic. + let mut last = self.lock_cycle().await; + if let Some(at) = *last { + if at.elapsed() < RECYCLE_COOLDOWN { + tracing::debug!( + "skipping meridian.db pool recycle - one ran less than {}s ago", + RECYCLE_COOLDOWN.as_secs() + ); + return false; + } + } + *last = Some(Instant::now()); + + tracing::warn!("recycling the meridian.db pool after a corrupt-view write failure"); + if tokio::time::timeout(RECYCLE_CLOSE_TIMEOUT, self.close()) + .await + .is_err() + { + // `close` already took the handle before awaiting, so this is safe to + // proceed through - the old pool is unreachable either way. + tracing::warn!( + timeout_s = RECYCLE_CLOSE_TIMEOUT.as_secs() as i64, + "pool close did not finish during recycle - reopening anyway" + ); + } + self.reopen().await; + + let recovered = self.get().is_some(); + if recovered { + tracing::info!("meridian.db pool recycled - writes should work again"); + } else { + tracing::warn!("meridian.db pool recycle did not yield a usable pool"); + } + recovered + } + + /// The pool, if one is currently open - `None` before the daemon has + /// created `meridian.db` yet, or during the brief window between + /// [`close`](Self::close) and [`reopen`](Self::reopen) around a daemon + /// restart. Cheap: `sqlx::SqlitePool` is itself `Arc`-backed, so this is + /// a shallow clone, not a new connection. + pub fn get(&self) -> Option { + self.inner.read().unwrap_or_else(|e| e.into_inner()).clone() + } + + /// Close the pool and clear the handle. Called before signaling the + /// daemon to restart, so nothing on this side keeps a connection alive + /// spanning the old process's shutdown and the new one's startup - see + /// this module's header for the corruption this closes off. Every reader + /// sees `get() == None` for the duration and behaves exactly as it + /// already does on a cold start (empty defaults, no panic). + pub async fn close(&self) { + let taken = self.inner.write().unwrap_or_else(|e| e.into_inner()).take(); + if let Some(pool) = taken { + pool.close().await; + } + } + + /// Reopen against the same uri/key this handle was built with. Lazy, + /// matching the original startup open - see that call site's doc + /// (`lib.rs`) for why eager fails when `meridian.db` briefly does not + /// exist. Best-effort: a failure here is logged and leaves `get()` + /// returning `None`, same as any other reason the pool isn't open yet; + /// the daemon's own re-creation of the file on its next write heals it + /// exactly as a lazy pool always has. + pub async fn reopen(&self) { + match meridian_core::open_existing_lazy(&self.uri, self.key_hex.as_deref()).await { + Ok(pool) => { + *self.inner.write().unwrap_or_else(|e| e.into_inner()) = Some(pool); + } + Err(e) => { + tracing::error!( + error = %e, + "DbPool::reopen failed - meridian.db stays unavailable until the next reload or tray restart" + ); + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/tray/src-tauri/src/db_pool/tests.rs b/tray/src-tauri/src/db_pool/tests.rs new file mode 100644 index 000000000..d1b983f87 --- /dev/null +++ b/tray/src-tauri/src/db_pool/tests.rs @@ -0,0 +1,276 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +//! Tests for the tray's swappable `meridian.db` pool handle. +//! +//! Split out of `mod.rs` for the 500-line file cap, following the same +//! `{mod,tests}.rs` shape as `meridian-core/src/pm_sync_requests/` and +//! `src/intelligence/providers/jira/`. + +use super::*; + +async fn migrated_db(dir: &std::path::Path) -> (String, meridian_core::SqlitePool) { + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use std::str::FromStr; + + let path = dir.join("db_pool_test.db"); + let uri = format!("sqlite://{}", path.display()); + // `open_existing`/`open_existing_lazy` both set `create_if_missing(false)` + // (they assume the daemon already created the file) - a test fixture + // needs its own connect path to create one from scratch. + let opts = SqliteConnectOptions::from_str(&uri) + .unwrap() + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .connect_with(opts) + .await + .expect("create db"); + sqlx::migrate!("../../src/migrations") + .run(&pool) + .await + .expect("migrate"); + pool.close().await; + // Reopen through the same lazy path `DbPool` itself uses, so the + // handle under test behaves exactly like production. + let pool = meridian_core::open_existing_lazy(&uri, None) + .await + .expect("reopen lazily"); + (uri, pool) +} + +/// `get()` must return exactly what was passed to `new()`. +#[tokio::test] +async fn get_returns_the_pool_it_was_built_with() { + let dir = tempfile::tempdir().unwrap(); + let (uri, pool) = migrated_db(dir.path()).await; + let handle = DbPool::new(Some(pool), uri, None); + assert!(handle.get().is_some()); +} + +/// A handle built with no pool (e.g. the DB isn't open yet) must behave +/// exactly like the old `None` state every caller already handles. +#[tokio::test] +async fn get_is_none_when_built_empty() { + let handle = DbPool::new(None, "sqlite://does-not-matter".to_string(), None); + assert!(handle.get().is_none()); +} + +/// The exact sequence `reload_daemon` runs: close, then a read in +/// between must see `None` (nothing races the daemon's restart), then +/// reopen brings a working pool back without needing to be told the URI +/// again. +#[tokio::test] +async fn close_then_reopen_restores_a_working_pool() { + let dir = tempfile::tempdir().unwrap(); + let (uri, pool) = migrated_db(dir.path()).await; + let handle = DbPool::new(Some(pool), uri, None); + + handle.close().await; + assert!( + handle.get().is_none(), + "a reader between close() and reopen() must see no pool, not a stale one" + ); + + handle.reopen().await; + let reopened = handle.get().expect("reopen must restore a pool"); + // Prove it's a genuinely live connection, not just a non-None marker. + meridian_core::ping(&reopened) + .await + .expect("reopened pool must actually work"); +} + +/// **The regression test for the 1.91.0-staging.2 write wedge.** +/// +/// A pool CLONE taken before `close()` is dead afterwards, while the handle +/// keeps working across the same close/reopen. That is the entire difference +/// between the old capture consumers (which cached +/// `Option` for the process lifetime and wrote through it every +/// ~2.5 s) and the current ones (which call `get()` per write). +/// +/// Asserting on the clone is what makes this a real guard rather than a +/// tautology: it proves the snapshot pattern is *observably* broken by a +/// daemon reload, so re-introducing it anywhere cannot look harmless. +#[tokio::test] +async fn a_pool_snapshot_dies_across_a_reload_but_the_handle_survives() { + let dir = tempfile::tempdir().unwrap(); + let (uri, pool) = migrated_db(dir.path()).await; + let handle = DbPool::new(Some(pool), uri, None); + + // What a long-lived consumer used to cache at startup. + let snapshot = handle.get().expect("a pool to snapshot"); + meridian_core::ping(&snapshot) + .await + .expect("the snapshot works before the reload"); + + // Exactly what `reload_daemon` does around every daemon restart. + handle.close().await; + handle.reopen().await; + + assert!( + meridian_core::ping(&snapshot).await.is_err(), + "a cached SqlitePool must be observably dead after close/reopen - if this \ + ever passes, the snapshot pattern looks safe and the capture wedge returns" + ); + + let fresh = handle.get().expect("the handle must still yield a pool"); + meridian_core::ping(&fresh) + .await + .expect("resolving the handle per use must survive the reload"); +} + +fn corrupt_err() -> anyhow::Error { + anyhow::anyhow!("error returned from database: (code: 11) database disk image is malformed") + .context("writing a PM sync request") +} + +/// The self-heal: a corrupt-VIEW write failure must give the caller a working +/// pool back, with no user action. +/// +/// This is what removes the relaunch. On 1.91.0-staging.2 a wedged pool stayed +/// wedged for the life of the tray process, and quitting the app was the only +/// cure - which no user could be expected to discover. +#[tokio::test] +async fn a_corrupt_write_recycles_the_pool_and_recovers() { + let dir = tempfile::tempdir().unwrap(); + let (uri, pool) = migrated_db(dir.path()).await; + let handle = DbPool::new(Some(pool), uri, None); + let before = handle.get().expect("a pool"); + + assert!( + handle.recover_if_corrupt(&corrupt_err()).await, + "a corrupt write must report that recovery succeeded" + ); + + // The connections that held the broken view are gone... + assert!( + meridian_core::ping(&before).await.is_err(), + "the recycled pool's old connections must be dropped, not reused" + ); + // ...and the caller can retry against a working one. + let after = handle.get().expect("a fresh pool after recycle"); + meridian_core::ping(&after) + .await + .expect("the recycled pool must actually work"); +} + +/// Only corruption may recycle. A locked database, a missing table or a pending +/// migration must leave the pool alone - dropping every connection on an ordinary +/// transient error would turn a blip into an outage. +#[tokio::test] +async fn an_unrelated_error_does_not_recycle_the_pool() { + let dir = tempfile::tempdir().unwrap(); + let (uri, pool) = migrated_db(dir.path()).await; + let handle = DbPool::new(Some(pool), uri, None); + let before = handle.get().expect("a pool"); + + let err = anyhow::anyhow!("database is locked").context("writing a PM sync request"); + assert!(!handle.recover_if_corrupt(&err).await); + + meridian_core::ping(&before) + .await + .expect("an unrelated error must leave the existing pool usable"); +} + +/// The cooldown. Capture writes every ~2.5 s, so a fault the recycle CANNOT fix +/// (real file damage, a revoked key) would otherwise become a close/reopen storm +/// on the file the daemon is also using. +#[tokio::test] +async fn a_second_corrupt_write_inside_the_cooldown_does_not_recycle_again() { + let dir = tempfile::tempdir().unwrap(); + let (uri, pool) = migrated_db(dir.path()).await; + let handle = DbPool::new(Some(pool), uri, None); + + assert!(handle.recover_if_corrupt(&corrupt_err()).await); + let after_first = handle.get().expect("a pool"); + + assert!( + !handle.recover_if_corrupt(&corrupt_err()).await, + "a second failure moments later must be refused, not recycled again" + ); + meridian_core::ping(&after_first) + .await + .expect("the refused attempt must not have torn down the working pool"); +} + +/// In-memory, schema-migrated. Enough for [`raise_if_corrupt`], which only +/// inspects the error it is handed and needs a `system_notices` table to +/// write into - real corrupted bytes on disk are not required, and +/// `sqlite::memory:` cannot be corrupted anyway (see +/// `src/db/test_corrupt.rs` for fixtures that can). +async fn fresh_db() -> SqlitePool { + use sqlx::sqlite::SqliteConnectOptions; + use std::str::FromStr; + let opts = SqliteConnectOptions::from_str("sqlite::memory:") + .unwrap() + .create_if_missing(true); + let pool = SqlitePool::connect_with(opts).await.unwrap(); + sqlx::migrate!("../../src/migrations") + .run(&pool) + .await + .unwrap(); + pool +} + +/// Whichever side of the app touches this pool must raise `db.corrupt` the +/// moment IT hits corruption, not wait for a daemon-side query to stumble +/// onto the same damage minutes later. `db::integrity::is_corrupt_error` +/// (the classifier this delegates to) is already pinned against the real +/// field-incident shape elsewhere. +#[tokio::test] +async fn raise_if_corrupt_writes_the_notice_on_a_corrupt_error() { + let pool = fresh_db().await; + let err = anyhow::anyhow!( + "error returned from database: (code: 11) database disk image is malformed" + ) + .context("current_task: fetch most recent task session"); + + raise_if_corrupt(&pool, &err).await; + + let row: (String, String) = + sqlx::query_as("SELECT severity, detail FROM system_notices WHERE notice_id = ?") + .bind(meridian::notices::DB_CORRUPT) + .fetch_one(&pool) + .await + .expect("db.corrupt notice must be written"); + assert_eq!(row.0, "error"); + assert!( + row.1.contains("database disk image is malformed"), + "notice detail dropped the actual cause: {}", + row.1 + ); +} + +/// Every other failure (a lock, a missing table, a network blip on an +/// unrelated call) must NOT raise the corruption banner — that would train +/// the user to run `db repair` for faults it can't fix. +#[tokio::test] +async fn raise_if_corrupt_is_silent_on_unrelated_errors() { + let pool = fresh_db().await; + let err = anyhow::anyhow!("database is locked").context("today: fetch sessions"); + + raise_if_corrupt(&pool, &err).await; + + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM system_notices WHERE notice_id = ?") + .bind(meridian::notices::DB_CORRUPT) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0, "an unrelated error must not raise db.corrupt"); +} + +/// `reopen` must not panic on failure, and must leave `get()` at `None` +/// rather than propagating the error - `reopen` is deliberately +/// best-effort (see its doc), the same shape as any other reason a lazy +/// pool isn't open yet. A missing/wrong-shaped file is NOT enough to +/// prove this (`open_existing_lazy` defers that check to first use, so +/// it would return `Ok` here regardless) - an invalid key is what +/// actually fails synchronously, at `validate_key_hex` inside +/// `open_existing_lazy` itself, before any connection is attempted. +#[tokio::test] +async fn reopen_failure_leaves_the_handle_empty_not_panicked() { + let handle = DbPool::new( + None, + "sqlite://does-not-matter".to_string(), + Some("not-valid-hex".to_string()), + ); + handle.reopen().await; + assert!(handle.get().is_none()); +} diff --git a/tray/src-tauri/src/lib.rs b/tray/src-tauri/src/lib.rs index a7a621472..c0ab8446b 100644 --- a/tray/src-tauri/src/lib.rs +++ b/tray/src-tauri/src/lib.rs @@ -936,8 +936,10 @@ pub fn run() { None, )); } - #[cfg(feature = "capture")] - let capture_pool = db_setup_result; + // (No `capture_pool` binding here any more: `start_capture` takes the + // managed `DbPool` handle and resolves it per write, so the raw + // `Option` this used to carry across to it is exactly the + // snapshot that wedged capture writes after a daemon restart.) // Single source of truth for the tray menu lives in `tray.rs`, so the // poll loop's health-driven rebuild can't drift out of sync. Initial @@ -1225,8 +1227,19 @@ pub fn run() { // that task, never the tray (we gave up the screenpipe daemon's process // isolation, so this matters). Frames → capture_frames (slice 4a), // input events → capture_ui_events (slice 3c). + // Pass the managed HANDLE, never `capture_pool` (the raw + // `Option` snapshot from setup) - see `start_capture`'s doc. + // Always `Some` by here: the fallback above registers an empty handle on + // the panic path, so this only skips capture if managed state is somehow + // absent entirely, which nothing else could work through either. #[cfg(feature = "capture")] - start_capture(app_state.clone(), capture_pool); + if let Some(db) = db_pool::from_app(app.handle()) { + start_capture(app_state.clone(), db); + } else { + tracing::error!( + "capture not started - the DbPool handle is not managed, so nothing could persist frames" + ); + } // Auto-open the setup wizard on first launch (no ~/.meridian/onboarded). // The 800 ms delay lets the tray menu settle before the window appears. @@ -1653,9 +1666,23 @@ fn tray_debug(window: tauri::Window, msg: String) { /// Once from `lib.rs`'s `setup()` on launch, and again from /// `commands::pause_for_duration` on resume. #[cfg(feature = "capture")] +/// Start (or restart) the in-process capture engine and its persisting consumers. +/// +/// # `db` is the HANDLE, deliberately not a pool +/// +/// The consumers below live for the whole tray process and write every ~2.5 s, so +/// they must resolve [`db_pool::DbPool::get`] **per write** rather than caching a +/// `SqlitePool`. This parameter used to be `Option` - a snapshot taken +/// once at start - and that was the 1.91.0-staging.2 write-wedge: `DbPool::close` +/// (run by `reload_daemon` around every daemon restart, precisely so the tray never +/// holds a connection across one) cannot reach a clone that escaped the handle, so +/// the capture connections spanned the daemon's shutdown WAL TRUNCATE checkpoint, +/// desynced their `-shm` view, and every subsequent write failed with `(code: 11) +/// database disk image is malformed` - permanently, on a database that was +/// provably healthy. See [`db_pool::from_app`] for the measured detail. pub(crate) fn start_capture( app_state: std::sync::Arc>, - pool: Option, + db: db_pool::DbPool, ) { use capture::{screenpipe::ScreenpipeEngine, CaptureEngine}; @@ -1729,7 +1756,10 @@ pub(crate) fn start_capture( // Handles both item shapes the engine can send — the primary per-tick frame // and secondary-monitor context samples (multi-screen capture) — through the // same ignore-list gate, routed to their own tables. - let consumer_pool = pool.clone(); + // The HANDLE, cloned (cheap - `Arc`-backed). Every write below resolves + // `.get()` afresh, so a daemon-restart close/reopen is followed rather than + // outlived. Never hoist this into a `SqlitePool` outside the loop. + let consumer_pool = db.clone(); let frame_ignore = capture_ignore.clone(); tauri::async_runtime::spawn(async move { while let Some(item) = rx.recv().await { @@ -1755,7 +1785,7 @@ pub(crate) fn start_capture( ); continue; } - let Some(p) = consumer_pool.as_ref() else { + let Some(p) = consumer_pool.get() else { continue; }; let row = meridian_core::CaptureFrameInsert { @@ -1766,8 +1796,20 @@ pub(crate) fn start_capture( text: frame.text, text_source: frame.text_source.as_str().to_string(), }; - if let Err(e) = meridian_core::insert_capture_frame(p, &row).await { - tracing::warn!(error = %e, "capture: failed to persist frame"); + if let Err(e) = meridian_core::insert_capture_frame(&p, &row).await { + // `cmd_err!`, not `%e`: this site logged only the outermost + // context (`insert capture_frame`) and threw the cause away, + // so a fleet-wide write wedge was undiagnosable from logs - + // the same swallowed-cause bug `cmd_err!` was written for. + let _ = crate::cmd_err!(e, "capture: failed to persist frame"); + // These writes land every ~2.5 s, which makes them both the + // first thing on the machine to notice a broken pool view and + // the natural place to heal it - so the app recovers within + // seconds instead of waiting for a relaunch the user has no + // reason to know about. No retry: the next frame is 2.5 s away + // and will use the fresh pool. + db_pool::raise_if_corrupt(&p, &e).await; + consumer_pool.recover_if_corrupt(&e).await; } } capture::CaptureItem::Secondary(sample) => { @@ -1789,7 +1831,7 @@ pub(crate) fn start_capture( ); continue; } - let Some(p) = consumer_pool.as_ref() else { + let Some(p) = consumer_pool.get() else { continue; }; let row = meridian_core::CaptureSecondaryScreenInsert { @@ -1799,8 +1841,13 @@ pub(crate) fn start_capture( window_name: sample.window_name, text: sample.text, }; - if let Err(e) = meridian_core::insert_capture_secondary_screen(p, &row).await { - tracing::warn!(error = %e, "capture: failed to persist secondary-screen sample"); + if let Err(e) = meridian_core::insert_capture_secondary_screen(&p, &row).await { + let _ = crate::cmd_err!( + e, + "capture: failed to persist secondary-screen sample" + ); + db_pool::raise_if_corrupt(&p, &e).await; + consumer_pool.recover_if_corrupt(&e).await; } } } @@ -1831,7 +1878,8 @@ pub(crate) fn start_capture( // UI event consumer: exits on cancel signal, which drops ui_rx, causing // the OS recorder thread to see tx.is_closed() within 500ms and exit. let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel::(256); - let ui_pool = pool; + // The handle, not a pool - same reason as `consumer_pool` above. + let ui_pool = db; let ui_ignore = capture_ignore; tauri::async_runtime::spawn(async move { loop { @@ -1845,9 +1893,11 @@ pub(crate) fn start_capture( if ui_ignore.lock().unwrap().should_drop_app(ev.app_name.as_deref()) { continue; } - let Some(p) = ui_pool.as_ref() else { continue }; - if let Err(e) = meridian_core::insert_capture_ui_event(p, &ev).await { - tracing::warn!(error = %e, "capture: failed to persist ui event"); + let Some(p) = ui_pool.get() else { continue }; + if let Err(e) = meridian_core::insert_capture_ui_event(&p, &ev).await { + let _ = crate::cmd_err!(e, "capture: failed to persist ui event"); + db_pool::raise_if_corrupt(&p, &e).await; + ui_pool.recover_if_corrupt(&e).await; } } } @@ -1882,3 +1932,50 @@ pub(crate) fn start_capture( s.ui_recorder_thread = Some(ui_recorder_thread); tracing::info!("capture: engine and ui recorder started"); } + +#[cfg(test)] +mod capture_pool_lifetime_tests { + /// The capture consumers must resolve the pool PER WRITE, never cache one. + /// + /// `start_capture` spawns three consumers that live for the whole tray + /// process and write every ~2.5 s. Until 1.91.0-staging.2 they captured + /// `Option` once at start, which `DbPool::close` (run by + /// `reload_daemon` around every daemon restart) cannot reach — so those + /// connections spanned the daemon's shutdown WAL TRUNCATE checkpoint, + /// desynced their `-shm` view, and every write from then on failed with + /// `(code: 11) database disk image is malformed` on a database that `db + /// check` reported healthy. Reads kept working, which is why it read as + /// corruption rather than as a connection bug. + /// + /// `a_pool_snapshot_dies_across_a_reload_but_the_handle_survives` in + /// `db_pool` proves the snapshot is dead after a reload; this proves these + /// particular call sites don't take one. It scans the source because the + /// consumers need a live tray app, an OS capture engine and a real daemon + /// restart to exercise — the same reason `main.rs`'s startup/shutdown + /// ordering tests scan rather than execute. + #[test] + fn capture_consumers_resolve_the_pool_per_write() { + const SRC: &str = include_str!("lib.rs"); + let prod = SRC + .split_once("\n#[cfg(test)]\nmod capture_pool_lifetime_tests") + .map_or(SRC, |(before, _)| before); + + for cached in ["let consumer_pool = pool.clone();", "let ui_pool = pool;"] { + assert!( + !prod.contains(cached), + "`{cached}` caches a SqlitePool for the capture consumers' whole \ + lifetime. Pass the `DbPool` handle and call `.get()` inside the \ + write loop instead - see `start_capture`'s doc for the wedge this \ + caused." + ); + } + + let handle_sites = + prod.matches("consumer_pool.get()").count() + prod.matches("ui_pool.get()").count(); + assert_eq!( + handle_sites, 3, + "expected all 3 capture write sites (frame, secondary-screen, ui event) \ + to resolve the handle per write, found {handle_sites}" + ); + } +} diff --git a/tray/src-tauri/src/poll/mod.rs b/tray/src-tauri/src/poll/mod.rs index 938218519..922842a77 100644 --- a/tray/src-tauri/src/poll/mod.rs +++ b/tray/src-tauri/src/poll/mod.rs @@ -116,7 +116,7 @@ pub async fn run_poll_loop(app: tauri::AppHandle, state: Arc>) { // check_disk_space's doc comment for why writing into a nearly-full // disk must stop rather than degrade silently. if let Some(pool) = &pool { - check_disk_space(&state, pool).await; + check_disk_space(&app, &state, pool).await; } // Work-hours schedule enforcement: auto-pause capture outside the // configured window, auto-resume when entering it. Only fires when the @@ -217,7 +217,11 @@ fn update_tray_icon(app: &tauri::AppHandle, state: &Arc>) { /// timer) is separately gated in [`crate::commands::pause::resume_capture`], /// so a still-low disk can't be resumed into from any direction — this /// function only owns the disk-low pause's own start/end transition. -async fn check_disk_space(state: &Arc>, pool: &meridian_core::SqlitePool) { +async fn check_disk_space( + app: &tauri::AppHandle, + state: &Arc>, + pool: &meridian_core::SqlitePool, +) { let low = meridian::health::platform::meridian_data_low_gb().is_some(); let (pause_source, started_at, capture_paused_flag) = { @@ -341,8 +345,11 @@ async fn check_disk_space(state: &Arc>, pool: &meridian_core::Sq // rare blip (engine starts and stops within the same tick, // capturing nothing) rather than a bug worth cross-checking // schedule state here too. + // The managed handle, not `pool` - see `start_capture`'s doc. #[cfg(feature = "capture")] - crate::start_capture(state.clone(), Some(pool.clone())); + if let Some(db) = crate::db_pool::from_app(app) { + crate::start_capture(state.clone(), db); + } tracing::info!(duration_s, "disk-space guard: capture resumed"); } _ => { @@ -438,9 +445,12 @@ async fn check_work_hours( s.schedule_resume_at = None; s.pause_until = None; } - // Restart engine so screen recording resumes. + // Restart engine so screen recording resumes. The managed handle, not + // `pool` - see `start_capture`'s doc. #[cfg(feature = "capture")] - crate::start_capture(state.clone(), Some(pool.clone())); + if let Some(db) = crate::db_pool::from_app(app) { + crate::start_capture(state.clone(), db); + } tracing::info!( duration_s, "work-hours: schedule pause ended — capture resumed" diff --git a/tray/src-tauri/src/poll/refresh.rs b/tray/src-tauri/src/poll/refresh.rs index 50ddccee4..cc0e6f424 100644 --- a/tray/src-tauri/src/poll/refresh.rs +++ b/tray/src-tauri/src/poll/refresh.rs @@ -392,47 +392,6 @@ fn decide_health_notice( } } -/// If `err` indicates `meridian.db` is corrupt, raise the SAME `db.corrupt` -/// notice `main.rs`'s `etl_tick` raises on the daemon side - immediately, -/// from whichever side of the app noticed first. -/// -/// The daemon already had this covered for its own queries, but the tray -/// holds its own independent, long-lived pool on the same file (opened once -/// at startup, `lib.rs`'s `app.manage(db_pool)`) and reads different tables -/// on this loop's faster (~30 s) cadence than the daemon's ETL/summariser -/// ticks. In the incident this fixes, the tray's own reads here hit -/// `(code: 11) database disk image is malformed` a full 5+ minutes before any -/// daemon-side query happened to touch the same damage - and until this -/// function existed, that whole window was silent `tracing::warn!` noise with -/// no banner, because nothing on this side of the process ever called -/// `raise_typed`. Idempotent (`raise_typed` upserts), so calling this on -/// every failing tick is safe and cheap - it does not need its own latch the -/// way the daemon's ETL loop does, because a poll tick that keeps failing -/// just keeps refreshing the same notice row rather than retrying a query -/// with side effects. -async fn raise_if_corrupt(pool: &SqlitePool, err: &anyhow::Error) { - if !meridian::db::integrity::is_corrupt_error(err) { - return; - } - let _ = meridian::notices::raise_typed( - pool, - meridian::notices::Notice { - id: meridian::notices::DB_CORRUPT, - severity: "error", - title: "Meridian's database is damaged", - // Full chain, not `err.to_string()` - same reasoning as - // `crate::cmd_err!`'s doc comment: `anyhow::Error`'s `Display` - // renders only the outermost `.context()` and would otherwise - // drop the SQLite code a reader needs. - detail: &format!("{err:#}"), - remedy: Some("Quit Meridian, then run 'meridian db repair' in a terminal"), - event_key: meridian::notices::DB_CORRUPT, - deep_link: Some(meridian_core::notifications::deep_links::LOGS), - }, - ) - .await; -} - /// Read the active session (direct DB) and store the app name + elapsed seconds. /// On a read error we keep the previous value rather than clearing the pill on a /// transient blip. @@ -453,7 +412,7 @@ pub(super) async fn refresh_active(pool: &SqlitePool, state: &Arc { tracing::warn!(error = %meridian::errors::chain(&e), "refresh_current_task failed"); - raise_if_corrupt(pool, &e).await; + crate::db_pool::raise_if_corrupt(pool, &e).await; } } } @@ -548,7 +507,7 @@ pub(super) async fn refresh_today(pool: &SqlitePool, state: &Arc } Err(e) => { tracing::warn!(error = %meridian::errors::chain(&e), "refresh_today failed"); - raise_if_corrupt(pool, &e).await; + crate::db_pool::raise_if_corrupt(pool, &e).await; } } } @@ -583,7 +542,7 @@ pub(super) async fn refresh_worklogs(pool: &SqlitePool, state: &Arc { tracing::warn!(error = %meridian::errors::chain(&e), "refresh_worklogs failed"); - raise_if_corrupt(pool, &e).await; + crate::db_pool::raise_if_corrupt(pool, &e).await; } } } @@ -995,54 +954,8 @@ mod tests { assert!(!recovered.reconcile_stale); } - /// The tray's own reads must raise `db.corrupt` the moment THEY hit - /// corruption, not wait for a daemon-side query to stumble onto the same - /// damage minutes later — the gap this fix closes. Real corrupted bytes on - /// disk aren't needed: `raise_if_corrupt` only inspects the error, and - /// `db::integrity::is_corrupt_error` (the classifier it delegates to) is - /// already pinned against the real field-incident shape elsewhere. - #[tokio::test] - async fn raise_if_corrupt_writes_the_notice_on_a_corrupt_error() { - let pool = fresh_db().await; - let err = anyhow::anyhow!( - "error returned from database: (code: 11) database disk image is malformed" - ) - .context("current_task: fetch most recent task session"); - - raise_if_corrupt(&pool, &err).await; - - let row: (String, String) = - sqlx::query_as("SELECT severity, detail FROM system_notices WHERE notice_id = ?") - .bind(meridian::notices::DB_CORRUPT) - .fetch_one(&pool) - .await - .expect("db.corrupt notice must be written"); - assert_eq!(row.0, "error"); - assert!( - row.1.contains("database disk image is malformed"), - "notice detail dropped the actual cause: {}", - row.1 - ); - } - - /// Every other read failure (a lock, a missing table, a network blip on an - /// unrelated call) must NOT raise the corruption banner — that would train - /// the user to run `db repair` for faults it can't fix. - #[tokio::test] - async fn raise_if_corrupt_is_silent_on_unrelated_errors() { - let pool = fresh_db().await; - let err = anyhow::anyhow!("database is locked").context("today: fetch sessions"); - - raise_if_corrupt(&pool, &err).await; - - let count: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM system_notices WHERE notice_id = ?") - .bind(meridian::notices::DB_CORRUPT) - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(count, 0, "an unrelated error must not raise db.corrupt"); - } + // `raise_if_corrupt`'s own tests moved with it to `crate::db_pool`, which + // is where the four call sites above now point. async fn fresh_db() -> meridian_core::SqlitePool { use sqlx::sqlite::SqliteConnectOptions; From 7b13f6df098d6e8a911fb3dc08ac1f0452fcfd87 Mon Sep 17 00:00:00 2001 From: adityaharishch Date: Thu, 27 Aug 2026 00:06:31 +0530 Subject: [PATCH 18/53] Revert "Merge pull request #910 from Meridiona/fix/pm-sync-outcome-and-pool-lifetime" This reverts commit d6befe402ba93daed346e6481cd3ebafc6c22825, reversing changes made to a6ee29697cac5d63b0ca1c2aa0c710b386a4ff86. --- meridian-core/src/pm_sync_requests/mod.rs | 267 +++----------- meridian-core/src/pm_sync_requests/tests.rs | 244 ++----------- src/intelligence/sync_delegate.rs | 64 ++-- src/intelligence/sync_requests.rs | 37 +- src/main.rs | 121 +------ src/migrations/083_pm_sync_request_seq.sql | 35 -- tray/src-tauri/src/commands/daemon.rs | 48 --- tray/src-tauri/src/commands/integrations.rs | 18 +- tray/src-tauri/src/commands/pause.rs | 8 +- .../src/commands/{tasks/mod.rs => tasks.rs} | 289 +++++---------- tray/src-tauri/src/commands/tasks/tests.rs | 162 --------- tray/src-tauri/src/db_pool.rs | 209 +++++++++++ tray/src-tauri/src/db_pool/mod.rs | 342 ------------------ tray/src-tauri/src/db_pool/tests.rs | 276 -------------- tray/src-tauri/src/lib.rs | 127 +------ tray/src-tauri/src/poll/mod.rs | 20 +- tray/src-tauri/src/poll/refresh.rs | 99 ++++- 17 files changed, 543 insertions(+), 1823 deletions(-) delete mode 100644 src/migrations/083_pm_sync_request_seq.sql rename tray/src-tauri/src/commands/{tasks/mod.rs => tasks.rs} (54%) delete mode 100644 tray/src-tauri/src/commands/tasks/tests.rs create mode 100644 tray/src-tauri/src/db_pool.rs delete mode 100644 tray/src-tauri/src/db_pool/mod.rs delete mode 100644 tray/src-tauri/src/db_pool/tests.rs diff --git a/meridian-core/src/pm_sync_requests/mod.rs b/meridian-core/src/pm_sync_requests/mod.rs index 8bc72cbb0..ed070c6b5 100644 --- a/meridian-core/src/pm_sync_requests/mod.rs +++ b/meridian-core/src/pm_sync_requests/mod.rs @@ -74,20 +74,8 @@ pub struct SyncRequest { pub provider: String, pub mode: SyncMode, pub reason: String, - /// The sequence number this claim covers - pass it back to [`complete`]. - /// - /// Captured at claim time on purpose: a request arriving DURING the sync bumps - /// `seq` past this value, so it stays pending and gets its own sync rather than - /// being silently marked done by work that started before it was asked for. - pub seq: i64, } -// Every query below spells "nothing has completed yet" as -// `COALESCE(completed_seq, 0)` and "pending" as `seq > COALESCE(completed_seq, 0)`. -// `seq` starts at 1, so 0 is unreachable as a real watermark. It lives inline in the -// SQL rather than as a Rust constant because it cannot be interpolated into a query -// string without giving up the compile-time-checked literal. - /// Ask the daemon to sync PM tasks. Idempotent and coalescing: repeated calls /// collapse into the single pending row rather than queueing, so opening the /// dashboard ten times means "a sync is wanted", not ten syncs. @@ -95,25 +83,10 @@ pub struct SyncRequest { /// `mode` **escalates only**. A `Force` landing on a pending `Gated` upgrades it, /// because a user who just connected a tracker must not have that downgraded by a /// passing window focus; a `Gated` landing on a pending `Force` leaves the `Force` -/// intact. -/// -/// # Returns the sequence number to wait on -/// -/// Pass the returned `seq` to [`outcome`]. It is what makes concurrent producers -/// safe, and it replaces the previous design where a new request cleared the -/// completion stamps outright. +/// intact. Writing a new request also clears any previous completion stamps, so the +/// row unambiguously represents work still to do. /// -/// Clearing them looked right - the row should represent work still to do - but it -/// destroyed the *answer* to a request already in flight, and the tracker-connect -/// flow always has several in flight at once (`oauth_connected`, -/// `token_connected`, and the user's own "Sync now", within a few seconds). The -/// completion of an earlier sync then matched nothing, the work was redone, and -/// every waiter timed out reporting failure for a sync that had in fact succeeded. -/// -/// So completion is now a **watermark**, never cleared: this only bumps `seq` and -/// re-opens the claim. A holder of seq N is satisfied by any `completed_seq >= N`. -/// -/// `reason` is a producer tag for tracing only (`"plan_or_picker"`, +/// `reason` is a producer tag for tracing only (`"dashboard_open"`, /// `"token_connected"`). Never pass user content - it is read back into logs. #[tracing::instrument(skip(pool))] pub async fn request( @@ -121,165 +94,84 @@ pub async fn request( provider: &str, mode: SyncMode, reason: &str, -) -> Result { - // A transaction, not `RETURNING`: the upsert and the read-back of `seq` must be - // atomic (a concurrent producer bumping `seq` in between would hand this caller - // a number it never wrote, making it wait on somebody else's sync), and - // `RETURNING` on an upsert needs SQLite 3.35+, which is not worth depending on - // when the SQLCipher build is the thing supplying the library. - let mut tx = pool - .begin() - .await - .context("opening a PM sync request transaction")?; - +) -> Result<()> { sqlx::query( "INSERT INTO pm_sync_requests - (provider, mode, reason, requested_at, - claimed_at, completed_at, error, synced_count, seq, completed_seq) - VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), - NULL, NULL, NULL, NULL, 1, NULL) + (provider, mode, reason, requested_at, claimed_at, completed_at, error, synced_count) + VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), NULL, NULL, NULL, NULL) ON CONFLICT(provider) DO UPDATE SET - -- The whole point: a new request is a new sequence number, so the - -- outcome of whatever is already running stays attributable to it. - seq = pm_sync_requests.seq + 1, -- Escalate to 'force', never back down from it while still pending. -- - -- The pendingness half is load-bearing: the row is kept after a sync - -- finishes (so \"Sync now\" can read its result), so without it a SPENT - -- 'force' would be inherited forever and every later gated request would - -- silently escalate. One tracker connect would then make every planner - -- open bypass the staleness gate and hit the provider for real - + -- The `completed_at IS NULL` half is load-bearing: the row is kept after a + -- sync finishes (so \"Sync now\" can read its result), so without it a + -- SPENT 'force' would be inherited forever and every later gated request + -- would silently escalate. One tracker connect would then make every + -- planner open bypass the staleness gate and hit the provider for real - -- reinstating the constant polling this whole design removes, and -- multiplying exactly the token refreshes it exists to reduce. mode = CASE WHEN excluded.mode = 'force' OR (pm_sync_requests.mode = 'force' - AND pm_sync_requests.seq > COALESCE(pm_sync_requests.completed_seq, 0)) + AND pm_sync_requests.completed_at IS NULL) THEN 'force' ELSE excluded.mode END, reason = excluded.reason, requested_at = excluded.requested_at, - -- Re-open the claim so the watcher sees pending work, but do NOT touch - -- completed_at / completed_seq / error / synced_count: those describe - -- the last sync that actually ran, and erasing them is what lost - -- outcomes. `seq` above is what marks this as new work. - claimed_at = NULL", + -- A fresh request re-opens the row: drop the in-flight and completion + -- marks so the watcher sees pending work again. + claimed_at = NULL, + completed_at = NULL, + error = NULL, + synced_count = NULL", ) .bind(provider) .bind(mode.as_str()) .bind(reason) - .execute(&mut *tx) + .execute(pool) .await .context("writing a PM sync request")?; - - let seq: i64 = sqlx::query_scalar("SELECT seq FROM pm_sync_requests WHERE provider = ?") - .bind(provider) - .fetch_one(&mut *tx) - .await - .context("reading back the PM sync request sequence")?; - - tx.commit().await.context("committing a PM sync request")?; - - tracing::debug!( - provider, - mode = mode.as_str(), - reason, - seq, - "PM sync requested" - ); - Ok(seq) -} - -/// Is there work to claim? A pure READ, so an idle consumer touches no locks. -/// -/// # Why this exists rather than just calling [`claim`] -/// -/// [`claim`] is an `UPDATE`, and SQLite opens a write transaction and takes a -/// RESERVED lock to evaluate one even when it matches no rows. The daemon's watcher -/// ticks every 2 s forever, so calling `claim` unconditionally meant **~43,000 write -/// transactions a day on an idle machine** - work that did not exist before this -/// outbox, on a file a second process also writes. Worse than the cost: it made -/// every daemon kill far more likely to land while a write transaction was open, -/// which is the profile behind the `-shm` desync that wedged writes on -/// 1.91.0-staging.2 with `(code: 11) database disk image is malformed` on a database -/// that was provably healthy. -/// -/// Gating on this read takes an idle daemon's write load to zero. WAL readers do not -/// take the write lock, so a tick that finds nothing is genuinely free. -/// -/// **Advisory only.** A `true` here can go stale before [`claim`] runs, and that is -/// fine: `claim` is still the conditional `UPDATE` that decides, so exclusivity is -/// unchanged and a lost race just yields `None`. A `false` that was wrong costs one -/// tick of latency. -pub async fn has_pending(pool: &SqlitePool, provider: &str) -> Result { - let found: Option = sqlx::query_scalar( - "SELECT 1 FROM pm_sync_requests - WHERE provider = ? - AND claimed_at IS NULL - AND seq > COALESCE(completed_seq, 0)", - ) - .bind(provider) - .fetch_optional(pool) - .await - .context("checking for a pending PM sync request")?; - Ok(found.is_some()) + tracing::debug!(provider, mode = mode.as_str(), reason, "PM sync requested"); + Ok(()) } /// Claim the pending request for `provider`, if there is one, marking it in-flight /// so a second watcher tick can't pick up the same work. /// -/// The claim is a conditional UPDATE (`WHERE claimed_at IS NULL AND `) -/// rather than a read-then-write, so two daemons racing on the same file - which the -/// single-instance guard makes unlikely but not impossible during a restart overlap - -/// cannot both claim it. SQLite serialises the statement, so exactly one sees a -/// non-zero `rows_affected`. -/// -/// Pending is `seq > COALESCE(completed_seq, 0)`, not `completed_at IS NULL`: the -/// completion stamps are a watermark now and are never cleared, so the only thing -/// that makes a row claimable again is [`request`] bumping `seq` past it. -/// -/// Reads `seq` back inside the same transaction as the claim, so the value handed to -/// [`complete`] is exactly the one this claim covers even if a producer bumps it a -/// microsecond later. +/// The claim is a conditional UPDATE (`WHERE claimed_at IS NULL AND completed_at IS +/// NULL`) rather than a read-then-write, so two daemons racing on the same file - +/// which the single-instance guard makes unlikely but not impossible during a +/// restart overlap - cannot both claim it. SQLite serialises the statement, so +/// exactly one sees a non-zero `rows_affected`. #[tracing::instrument(skip(pool))] pub async fn claim(pool: &SqlitePool, provider: &str) -> Result> { - let mut tx = pool - .begin() - .await - .context("opening a PM sync claim transaction")?; - let claimed = sqlx::query( "UPDATE pm_sync_requests SET claimed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE provider = ? AND claimed_at IS NULL - AND seq > COALESCE(completed_seq, 0)", + AND completed_at IS NULL", ) .bind(provider) - .execute(&mut *tx) + .execute(pool) .await .context("claiming a PM sync request")?; if claimed.rows_affected() == 0 { - // Nothing to do - roll back rather than commit an empty transaction. return Ok(None); } - let row: Option<(String, String, i64)> = - sqlx::query_as("SELECT mode, reason, seq FROM pm_sync_requests WHERE provider = ?") + let row: Option<(String, String)> = + sqlx::query_as("SELECT mode, reason FROM pm_sync_requests WHERE provider = ?") .bind(provider) - .fetch_optional(&mut *tx) + .fetch_optional(pool) .await .context("reading the claimed PM sync request")?; - tx.commit().await.context("committing a PM sync claim")?; - - Ok(row.map(|(mode, reason, seq)| SyncRequest { + Ok(row.map(|(mode, reason)| SyncRequest { provider: provider.to_string(), mode: SyncMode::from_str_or_gated(&mode), reason, - seq, })) } @@ -287,65 +179,35 @@ pub async fn claim(pool: &SqlitePool, provider: &str) -> Result COALESCE(completed_seq, 0)` keeps the protection and loses the -/// bug. The watermark only ever moves forward, so this is idempotent and a late -/// duplicate cannot roll it back; a newer request has a HIGHER `seq` than the one -/// being completed, so it stays pending and gets its own sync. `claimed_at` is -/// cleared here rather than depended on, which is what makes that next sync -/// claimable immediately. +/// Writes only if the row is still the one that was claimed. The guard is +/// **`claimed_at IS NOT NULL`**, and that specific predicate is the whole point: +/// [`request`] resets `claimed_at` to `NULL`, so a request that arrived mid-sync +/// makes this UPDATE match nothing. Guarding on `completed_at IS NULL` alone would +/// NOT work - the fresh request leaves that NULL too, so the older sync's outcome +/// would stamp the new request as done without it ever being serviced, and the +/// user's "Sync now" would report success for a sync that never ran. #[tracing::instrument(skip(pool))] pub async fn complete( pool: &SqlitePool, provider: &str, - seq: i64, synced_count: Option, error: Option<&str>, ) -> Result<()> { - let res = sqlx::query( + sqlx::query( "UPDATE pm_sync_requests - SET completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), - completed_seq = ?, - claimed_at = NULL, - error = ?, - synced_count = ? + SET completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), + error = ?, + synced_count = ? WHERE provider = ? - AND ? > COALESCE(completed_seq, 0)", + AND claimed_at IS NOT NULL + AND completed_at IS NULL", ) - .bind(seq) .bind(error) .bind(synced_count) .bind(provider) - .bind(seq) .execute(pool) .await .context("completing a PM sync request")?; - - if res.rows_affected() == 0 { - // Not an error: a duplicate or out-of-order completion for a watermark that - // has already moved past `seq`. Worth a line, because it should be rare and - // a steady stream of them would mean two consumers are running. - tracing::debug!( - provider, - seq, - "PM sync completion ignored - the watermark is already at or past it" - ); - } Ok(()) } @@ -367,7 +229,7 @@ pub async fn reset_stale_claims(pool: &SqlitePool) -> Result { "UPDATE pm_sync_requests SET claimed_at = NULL WHERE claimed_at IS NOT NULL - AND seq > COALESCE(completed_seq, 0)", + AND completed_at IS NULL", ) .execute(pool) .await @@ -382,24 +244,12 @@ pub async fn reset_stale_claims(pool: &SqlitePool) -> Result { Ok(n) } -/// The outcome for the request the caller wrote, for a producer that wants to show -/// one ("Sync now"). `None` while that request is still pending or in flight, so a -/// caller can poll this until it turns `Some`. -/// -/// `want_seq` is the value [`request`] returned. `Some` means `completed_seq >= -/// want_seq` - i.e. a sync that finished at or after the caller's request, which is -/// what makes overlapping producers safe: two waiters can be satisfied by one sync, -/// and neither can be handed the result of a sync that finished BEFORE it asked. -/// -/// Passing a stale `want_seq` (from a previous request) therefore returns a result -/// immediately, by design - it has genuinely been satisfied. -pub async fn outcome( - pool: &SqlitePool, - provider: &str, - want_seq: i64, -) -> Result> { - let row: Option<(Option, Option, Option)> = sqlx::query_as( - "SELECT completed_seq, error, synced_count FROM pm_sync_requests WHERE provider = ?", +/// The outcome of the last request for `provider`, for a producer that wants to +/// show one ("Sync now"). `None` while the request is still pending or in flight, +/// so a caller can poll this until it turns `Some`. +pub async fn outcome(pool: &SqlitePool, provider: &str) -> Result> { + let row: Option<(Option, Option, Option)> = sqlx::query_as( + "SELECT completed_at, error, synced_count FROM pm_sync_requests WHERE provider = ?", ) .bind(provider) .fetch_optional(pool) @@ -407,14 +257,11 @@ pub async fn outcome( .context("reading the PM sync outcome")?; Ok(match row { - Some((Some(completed_seq), error, synced_count)) if completed_seq >= want_seq => { - Some(SyncOutcome { - error, - synced_count, - }) - } - // No row, nothing completed yet, or the watermark has not reached this - // caller's request. + Some((Some(_completed), error, synced_count)) => Some(SyncOutcome { + error, + synced_count, + }), + // No row, or a row still pending / in flight. _ => None, }) } diff --git a/meridian-core/src/pm_sync_requests/tests.rs b/meridian-core/src/pm_sync_requests/tests.rs index 1877f34a7..2bf861c66 100644 --- a/meridian-core/src/pm_sync_requests/tests.rs +++ b/meridian-core/src/pm_sync_requests/tests.rs @@ -10,23 +10,26 @@ use super::*; use sqlx::sqlite::SqliteConnectOptions; use std::str::FromStr; -/// The table as the REAL migrations build it, not a hand-written copy. -/// -/// It used to be a hand-written `CREATE TABLE` mirroring migration 082. That is a -/// schema the tests can silently diverge from: adding `seq`/`completed_seq` in -/// migration 083 left every one of these tests passing against a table that did not -/// have the columns the queries now use, so the suite proved nothing about the code -/// that shipped. Running the migrator instead means these tests also assert that -/// 082 + 083 actually apply in order, which is the property real installs depend on. async fn db() -> SqlitePool { let opts = SqliteConnectOptions::from_str("sqlite::memory:") .unwrap() .create_if_missing(true); let pool = SqlitePool::connect_with(opts).await.unwrap(); - sqlx::migrate!("../src/migrations") - .run(&pool) - .await - .unwrap(); + sqlx::query( + "CREATE TABLE pm_sync_requests ( + provider TEXT NOT NULL PRIMARY KEY, + mode TEXT NOT NULL DEFAULT 'gated', + reason TEXT NOT NULL DEFAULT '', + requested_at TEXT NOT NULL, + claimed_at TEXT, + completed_at TEXT, + error TEXT, + synced_count INTEGER + )", + ) + .execute(&pool) + .await + .unwrap(); pool } @@ -89,9 +92,7 @@ async fn a_completed_force_does_not_escalate_the_next_gated_request() { .await .unwrap(); claim(&pool, ALL_PROVIDERS).await.unwrap(); - complete(&pool, ALL_PROVIDERS, 1, Some(4), None) - .await - .unwrap(); + complete(&pool, ALL_PROVIDERS, Some(4), None).await.unwrap(); // A later window open wants the cheap, gated behaviour. request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") @@ -107,9 +108,8 @@ async fn a_completed_force_does_not_escalate_the_next_gated_request() { } /// The in-flight case still escalates: a force that is claimed but not completed -/// gets a NEW sequence number from the arriving request, so it stays pending past -/// the running sync's completion and is serviced again - and the force intent must -/// survive into that re-run rather than being downgraded to the arriving gated mode. +/// will have its outcome discarded by `complete`'s guard and be re-serviced, so +/// the force intent must survive into that re-run. #[tokio::test] async fn an_in_flight_force_still_survives_a_gated_request() { let pool = db().await; @@ -184,9 +184,7 @@ async fn reset_leaves_completed_requests_alone() { .await .unwrap(); claim(&pool, ALL_PROVIDERS).await.unwrap(); - complete(&pool, ALL_PROVIDERS, 1, Some(2), None) - .await - .unwrap(); + complete(&pool, ALL_PROVIDERS, Some(2), None).await.unwrap(); assert_eq!(reset_stale_claims(&pool).await.unwrap(), 0); assert!( @@ -203,21 +201,16 @@ async fn outcome_is_none_until_completed() { request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") .await .unwrap(); - assert!(outcome(&pool, ALL_PROVIDERS, 1).await.unwrap().is_none()); + assert!(outcome(&pool, ALL_PROVIDERS).await.unwrap().is_none()); claim(&pool, ALL_PROVIDERS).await.unwrap(); assert!( - outcome(&pool, ALL_PROVIDERS, 1).await.unwrap().is_none(), + outcome(&pool, ALL_PROVIDERS).await.unwrap().is_none(), "in-flight must still read as pending" ); - complete(&pool, ALL_PROVIDERS, 1, Some(7), None) - .await - .unwrap(); - let out = outcome(&pool, ALL_PROVIDERS, 1) - .await - .unwrap() - .expect("done"); + complete(&pool, ALL_PROVIDERS, Some(7), None).await.unwrap(); + let out = outcome(&pool, ALL_PROVIDERS).await.unwrap().expect("done"); assert_eq!(out.synced_count, Some(7)); assert!(out.error.is_none()); } @@ -230,205 +223,44 @@ async fn outcome_carries_the_error() { .await .unwrap(); claim(&pool, ALL_PROVIDERS).await.unwrap(); - complete(&pool, ALL_PROVIDERS, 1, None, Some("401 unauthorized")) + complete(&pool, ALL_PROVIDERS, None, Some("401 unauthorized")) .await .unwrap(); - let out = outcome(&pool, ALL_PROVIDERS, 1) - .await - .unwrap() - .expect("done"); + let out = outcome(&pool, ALL_PROVIDERS).await.unwrap().expect("done"); assert_eq!(out.error.as_deref(), Some("401 unauthorized")); } -/// **THE BUG THIS FILE EXISTS FOR, and both halves of it at once.** -/// -/// A request arriving mid-sync must not be marked done by the sync that was already -/// running (or "Sync now" reports success for work that never ran), AND the sync that -/// was already running must still be able to report its result to whoever asked for -/// it (or every waiter times out and reports failure for a sync that succeeded). -/// -/// The 082 design could only get one of those. It guarded `complete` on `claimed_at -/// IS NOT NULL`, which the new request had just nulled, so the completion was -/// discarded entirely - protecting the new request by throwing away the old -/// request's answer. On 1.91.0-staging.2 that was the normal case rather than an -/// edge one, because connecting a tracker fires `oauth_connected`, -/// `token_connected` and the user's "Sync now" within a few seconds: the sync -/// worked, the answer was dropped, the work was repeated, and the user saw a -/// failure. -/// -/// The sequence watermark gets both. Asserting only the first half is what let the -/// bug ship, so this test asserts them together. +/// THE RACE THIS GUARDS: a request arriving mid-sync resets the row, and the +/// older sync's outcome must NOT stamp it complete - that would mark the new +/// request done without ever servicing it. #[tokio::test] -async fn a_mid_sync_request_neither_steals_nor_destroys_the_running_sync_s_outcome() { +async fn completion_does_not_clobber_a_request_that_arrived_mid_sync() { let pool = db().await; - let first = request(&pool, ALL_PROVIDERS, SyncMode::Gated, "plan_or_picker") - .await - .unwrap(); - let claimed = claim(&pool, ALL_PROVIDERS).await.unwrap().expect("pending"); - assert_eq!( - claimed.seq, first, - "the claim must cover the request it read" - ); - - // A second producer asks while the first sync is still running - the connect - // flow does exactly this. - let second = request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") + request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") .await .unwrap(); - assert!(second > first, "a new request must advance the sequence"); + claim(&pool, ALL_PROVIDERS).await.unwrap(); - // The in-flight sync finishes and reports against the seq it claimed. - complete(&pool, ALL_PROVIDERS, claimed.seq, Some(3), None) + // A new request lands while the first sync is still running. + request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") .await .unwrap(); - // Half one - the FIX. The first waiter gets its answer instead of timing out. - let out = outcome(&pool, ALL_PROVIDERS, first) - .await - .unwrap() - .expect("the waiter that asked for this sync must receive its outcome"); - assert_eq!(out.synced_count, Some(3)); + // The in-flight sync finishes and tries to report. This must be a NO-OP: + // the new request cleared `claimed_at`, so the guard rejects it. + complete(&pool, ALL_PROVIDERS, Some(3), None).await.unwrap(); - // Half two - the ORIGINAL PROTECTION, preserved. The later request was not - // serviced by work that started before it existed. assert!( - outcome(&pool, ALL_PROVIDERS, second) - .await - .unwrap() - .is_none(), - "a request made mid-sync must NOT be satisfied by the sync already running - \ + outcome(&pool, ALL_PROVIDERS).await.unwrap().is_none(), + "the older sync's outcome must NOT mark the new request complete - \ that would report success for a sync that never ran" ); - // ...and it is still serviceable, with its escalation intact. let req = claim(&pool, ALL_PROVIDERS) .await .unwrap() .expect("the mid-sync request must still be pending"); assert_eq!(req.mode, SyncMode::Force); assert_eq!(req.reason, "sync_now"); - assert_eq!(req.seq, second); -} - -/// Two waiters, one sync: the whole point of a watermark. Both producers asked -/// before anything was serviced, so one sync must satisfy both rather than each -/// needing its own provider round trip. -#[tokio::test] -async fn one_sync_satisfies_every_waiter_that_asked_before_it_ran() { - let pool = db().await; - let a = request(&pool, ALL_PROVIDERS, SyncMode::Force, "oauth_connected") - .await - .unwrap(); - let b = request(&pool, ALL_PROVIDERS, SyncMode::Force, "token_connected") - .await - .unwrap(); - - let claimed = claim(&pool, ALL_PROVIDERS).await.unwrap().expect("pending"); - complete(&pool, ALL_PROVIDERS, claimed.seq, Some(11), None) - .await - .unwrap(); - - for (label, seq) in [("first", a), ("second", b)] { - assert!( - outcome(&pool, ALL_PROVIDERS, seq).await.unwrap().is_some(), - "the {label} waiter must be satisfied by the single sync that covered it" - ); - } - assert!( - claim(&pool, ALL_PROVIDERS).await.unwrap().is_none(), - "coalesced requests must not leave extra work behind - that is a second \ - provider round trip for one user action" - ); -} - -/// `has_pending` is the gate that keeps an IDLE daemon from writing at all. -/// -/// The watcher ticks every 2 s forever and used to call `claim` unconditionally - an -/// `UPDATE`, so SQLite opened a write transaction and took a lock even when it -/// matched nothing. That was ~43,000 write transactions a day on an idle machine, -/// against a file a second process also writes, and it made every daemon kill far -/// likelier to land mid-write. -/// -/// So the states where the answer must be `false` matter more than the one where it -/// is `true`: each is a tick that now touches no lock at all. -#[tokio::test] -async fn has_pending_is_false_in_every_idle_state() { - let pool = db().await; - - assert!( - !has_pending(&pool, ALL_PROVIDERS).await.unwrap(), - "a fresh install with no row must not provoke a claim - this is the state \ - almost every tick runs in" - ); - - request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") - .await - .unwrap(); - assert!( - has_pending(&pool, ALL_PROVIDERS).await.unwrap(), - "real work must still be seen" - ); - - let claimed = claim(&pool, ALL_PROVIDERS).await.unwrap().expect("pending"); - assert!( - !has_pending(&pool, ALL_PROVIDERS).await.unwrap(), - "an in-flight request is not claimable, so ticks during a long sync must be \ - free too" - ); - - complete(&pool, ALL_PROVIDERS, claimed.seq, Some(2), None) - .await - .unwrap(); - assert!( - !has_pending(&pool, ALL_PROVIDERS).await.unwrap(), - "a completed row is the steady state after any sync - it must never read as \ - work, or the daemon would re-sync forever" - ); - - request(&pool, ALL_PROVIDERS, SyncMode::Gated, "plan_or_picker") - .await - .unwrap(); - assert!( - has_pending(&pool, ALL_PROVIDERS).await.unwrap(), - "a new request after a completion must be visible again" - ); -} - -/// A duplicate or out-of-order completion must never move the watermark backwards, -/// so a retrying consumer cannot un-answer a waiter that was already satisfied. -#[tokio::test] -async fn the_completion_watermark_only_moves_forward() { - let pool = db().await; - request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") - .await - .unwrap(); - let first = claim(&pool, ALL_PROVIDERS).await.unwrap().expect("pending"); - complete(&pool, ALL_PROVIDERS, first.seq, Some(5), None) - .await - .unwrap(); - - let second = request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") - .await - .unwrap(); - let claimed = claim(&pool, ALL_PROVIDERS).await.unwrap().expect("pending"); - complete(&pool, ALL_PROVIDERS, claimed.seq, Some(9), None) - .await - .unwrap(); - - // A late duplicate for the OLD seq arrives (a retry, a doubled tick). - complete(&pool, ALL_PROVIDERS, first.seq, Some(1), Some("stale")) - .await - .unwrap(); - - let out = outcome(&pool, ALL_PROVIDERS, second) - .await - .unwrap() - .expect("the newer waiter must stay satisfied"); - assert_eq!( - out.synced_count, - Some(9), - "the stale retry overwrote the result" - ); - assert_eq!(out.error, None, "the stale retry resurrected an old error"); } diff --git a/src/intelligence/sync_delegate.rs b/src/intelligence/sync_delegate.rs index f2ddc7cce..8475390f9 100644 --- a/src/intelligence/sync_delegate.rs +++ b/src/intelligence/sync_delegate.rs @@ -152,45 +152,29 @@ async fn request_and_wait( label: &str, wait: Option, ) -> Delegation { - let seq = match pm_sync_requests::request(pool, ALL_PROVIDERS, mode, label).await { - Ok(seq) => seq, - Err(e) => { - return Delegation::Failed { - error: format!( - "could not queue the sync request: {}", - crate::errors::chain(&e) - ), - }; - } - }; - tracing::debug!( - label, - seq, - "pm sync requested - the daemon owns tracker auth" - ); + if let Err(e) = pm_sync_requests::request(pool, ALL_PROVIDERS, mode, label).await { + return Delegation::Failed { + error: format!( + "could not queue the sync request: {}", + crate::errors::chain(&e) + ), + }; + } + tracing::debug!(label, "pm sync requested - the daemon owns tracker auth"); match wait { - Some(budget) => wait_for_outcome(pool, label, seq, budget).await, + Some(budget) => wait_for_outcome(pool, label, budget).await, None => Delegation::Pending, } } /// Poll the request row until the daemon records an outcome, or `budget` elapses. /// -/// The row is keyed on the provider (`'*'`), so a concurrent producer's request shares -/// it. `seq` is what keeps that safe: it is the sequence number THIS caller's request -/// was given, and `outcome` only answers once the completion watermark reaches it. So a -/// sibling's sync can satisfy this caller (both asked for the same thing, and "a sync -/// finished after you asked" is exactly what the caller needs to know), while a sync that -/// finished BEFORE this request cannot - which is the stale read the previous -/// `completed_at`-clearing design tried to prevent by destroying the other waiter's -/// answer. -async fn wait_for_outcome( - pool: &SqlitePool, - label: &str, - seq: i64, - budget: Duration, -) -> Delegation { +/// The row is keyed on the provider (`'*'`), so a concurrent CLI's request can reset it +/// and this can end up reading a sibling's outcome. That is fine: both asked for the same +/// thing, and "some sync just completed" is exactly what the caller needs to know. It +/// cannot read a *stale* outcome, because `request` clears `completed_at`. +async fn wait_for_outcome(pool: &SqlitePool, label: &str, budget: Duration) -> Delegation { let deadline = std::time::Instant::now() + budget; loop { if std::time::Instant::now() >= deadline { @@ -202,7 +186,7 @@ async fn wait_for_outcome( return Delegation::Pending; } tokio::time::sleep(POLL_INTERVAL).await; - match pm_sync_requests::outcome(pool, ALL_PROVIDERS, seq).await { + match pm_sync_requests::outcome(pool, ALL_PROVIDERS).await { Ok(Some(out)) => { return match out.error { Some(error) => Delegation::Failed { error }, @@ -252,7 +236,7 @@ mod tests { assert_eq!(got, Delegation::Synced { count: None }); assert!( - pm_sync_requests::outcome(&pool, ALL_PROVIDERS, 1) + pm_sync_requests::outcome(&pool, ALL_PROVIDERS) .await .unwrap() .is_none(), @@ -305,19 +289,13 @@ mod tests { .await .unwrap(); pm_sync_requests::claim(&pool, ALL_PROVIDERS).await.unwrap(); - pm_sync_requests::complete( - &pool, - ALL_PROVIDERS, - 1, - None, - Some("refresh_token is invalid"), - ) - .await - .unwrap(); + pm_sync_requests::complete(&pool, ALL_PROVIDERS, None, Some("refresh_token is invalid")) + .await + .unwrap(); // Re-requesting clears the outcome, so the waiter must be the one to observe it: // drive the wait directly against the already-completed row. - let got = wait_for_outcome(&pool, "tasks-sync", 1, Duration::from_secs(5)).await; + let got = wait_for_outcome(&pool, "tasks-sync", Duration::from_secs(5)).await; assert_eq!( got, diff --git a/src/intelligence/sync_requests.rs b/src/intelligence/sync_requests.rs index a60ea29c5..8e1838909 100644 --- a/src/intelligence/sync_requests.rs +++ b/src/intelligence/sync_requests.rs @@ -100,17 +100,9 @@ pub async fn run_watcher(pool: SqlitePool, mut shutdown_rx: watch::Receiver return, - Ok(true) => {} + let req = match pm_sync_requests::claim(pool, ALL_PROVIDERS).await { + Ok(Some(req)) => req, + Ok(None) => return, Err(e) => { // Logged at debug, not warn: on a fresh install this fires every 2 s // until migration 082 has run, and a warn-level line every 2 s would @@ -122,20 +114,6 @@ async fn service_once(pool: &SqlitePool) { ); return; } - } - - let req = match pm_sync_requests::claim(pool, ALL_PROVIDERS).await { - Ok(Some(req)) => req, - // Lost the race to another claimant between the read and here - fine, and - // the reason `has_pending` is documented as advisory. - Ok(None) => return, - Err(e) => { - tracing::debug!( - error = %crate::errors::chain(&e), - "could not claim a PM sync request" - ); - return; - } }; // Config is read here rather than captured at spawn time so a settings change @@ -146,7 +124,6 @@ async fn service_once(pool: &SqlitePool) { tracing::info!( mode = req.mode.as_str(), reason = %req.reason, - seq = req.seq, "servicing PM sync request" ); @@ -180,9 +157,7 @@ async fn service_once(pool: &SqlitePool) { } }; - if let Err(e) = - pm_sync_requests::complete(pool, &req.provider, req.seq, count, error.as_deref()).await - { + if let Err(e) = pm_sync_requests::complete(pool, &req.provider, count, error.as_deref()).await { tracing::warn!( error = %crate::errors::chain(&e), "could not record the PM sync outcome - the producer will keep waiting" @@ -211,7 +186,7 @@ mod tests { async fn service_once_is_quiet_with_no_requests() { let pool = db().await; service_once(&pool).await; - assert!(pm_sync_requests::outcome(&pool, ALL_PROVIDERS, 1) + assert!(pm_sync_requests::outcome(&pool, ALL_PROVIDERS) .await .unwrap() .is_none()); @@ -229,7 +204,7 @@ mod tests { service_once(&pool).await; - let out = pm_sync_requests::outcome(&pool, ALL_PROVIDERS, 1) + let out = pm_sync_requests::outcome(&pool, ALL_PROVIDERS) .await .unwrap() .expect("the request must be completed, not left pending"); diff --git a/src/main.rs b/src/main.rs index f0690dae3..157153b91 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1534,21 +1534,13 @@ async fn main() -> Result<()> { // ever on a row a producer wrote, so every refresh still traces to a human // action, which is what keeps a refresh POST from being in flight when a // laptop lid closes. - // - // Its `JoinHandle` is KEPT, unlike the loops above, and awaited in the - // shutdown sequence before the WAL checkpoint. This task is the daemon's - // only writer outside the poll loop, and `service_once` deliberately runs a - // whole sync (network + `pm_tasks` writes, tens of seconds) without - // re-checking the shutdown flag, so dropping the handle meant checkpointing - // and closing the pool underneath live writes on every restart. See the - // shutdown site for the full reasoning. - let sync_watcher = { + { let pool_sync = meridian.clone(); let rx_sync = shutdown_rx.clone(); tokio::spawn(async move { meridian::intelligence::sync_requests::run_watcher(pool_sync, rx_sync).await; - }) - }; + }); + } // 8b. Poll loop — ETL, PM sync, and FM categorization on the configured interval. // Track the last-applied log level so we can detect changes and hot-reload @@ -1704,45 +1696,7 @@ async fn main() -> Result<()> { // 9. Shutdown tracing::info!(pid = std::process::id() as i64, "shutting down"); - - // 9a. Wait for the PM sync watcher to actually STOP before touching the WAL. - // - // `shutdown_tx.send(true)` above only sets a flag, and the watcher checks it - // on its sleep - not around `service_once`, which is deliberate (a claimed - // request finishes and records an outcome rather than being cut off with the - // row left claimed). The consequence is that after the signal this task can - // still be inside a full provider sync for tens of seconds, writing to - // `pm_tasks`. Without this await, `checkpoint_wal` and `close` below ran - // underneath those writes on EVERY restart - and a reconnect flow restarts - // the daemon, which is exactly when a burst of sync requests exists to - // service. A TRUNCATE checkpoint racing live writes is the one thing that - // function exists to prevent (see its doc: it hands the next generation a - // half-written WAL while the tray keeps a stale view of the file). - // - // Bounded, because the whole point of the no-interrupt design is that - // `service_once` may be mid-network-call: on timeout we proceed anyway and - // say so, which is strictly better than the old unconditional race, and no - // worse than a hard kill. - { - const WATCHER_DRAIN_TIMEOUT: Duration = Duration::from_secs(45); - match tokio::time::timeout(WATCHER_DRAIN_TIMEOUT, sync_watcher).await { - Ok(Ok(())) => tracing::debug!("PM sync watcher stopped cleanly before checkpoint"), - // `JoinError`'s Display ignores `f.alternate()`, so `chain()` would - // render byte-identically - it carries a panic payload or a - // cancellation, never a `.context()` chain. - Ok(Err(e)) => { - tracing::warn!(error = %e, "sync watcher ended abnormally"); // not-anyhow: JoinError - } - Err(_elapsed) => tracing::warn!( - timeout_s = WATCHER_DRAIN_TIMEOUT.as_secs() as i64, - "PM sync watcher did not stop in time - checkpointing anyway, which may leave a non-empty WAL" - ), - } - } - - // (`release_endpoint` used to be here. It now runs at 9b, after the pool is - // closed - see there for why the exiting side's ordering matters too.) - + meridian::platform::release_endpoint(); // See `db::meridian::checkpoint_wal`'s doc for why this runs before every // close, not just a plain shutdown. Best-effort: a failed checkpoint must // not block shutdown. @@ -1765,19 +1719,6 @@ async fn main() -> Result<()> { } meridian.close().await; - // 9b. Release the single-instance endpoint LAST, after the pool is closed. - // - // This used to run first, before the checkpoint. `daemon_already_running` is - // how a starting daemon decides whether to bow out (4a-ter), so releasing it - // early opens the guard while THIS process is still checkpointing and closing - // - the window `single_instance_check_precedes_setup_db_and_bind_follows_it` - // exists to close, reopened from the exiting side. It only pinned the - // STARTING daemon's ordering. Under launchd the relaunch is immediate, and a - // new generation running migrations against a file the old one is mid- - // checkpoint on is the double-writer profile the fleet-correlated corruption - // was traced to. - meridian::platform::release_endpoint(); - // Flush OTel exporters FIRST, while the runtime is alive — this writes the // daemon's final shutdown spans/logs into the spool's pending/ dir... obs_guard.shutdown().await; @@ -2005,58 +1946,4 @@ mod startup_order_tests { bind at byte {bind_pos}." ); } - - /// The EXIT-side ordering, which the test above does not cover and which was - /// wrong until 1.91.0-staging.2's write wedge was traced. - /// - /// Three things must happen in this order on shutdown: - /// 1. await the PM sync watcher — it is the daemon's only writer outside the - /// poll loop, and `service_once` deliberately ignores the shutdown flag - /// once it has claimed a row (so it can finish and record an outcome), so - /// it can still be writing for tens of seconds after the signal; - /// 2. `checkpoint_wal` — a TRUNCATE checkpoint racing those live writes is - /// the exact thing that function exists to prevent, and it is what hands - /// the next daemon generation a half-written WAL while the tray keeps a - /// stale view of the file; - /// 3. `release_endpoint` LAST — it is what `daemon_already_running` answers, - /// so releasing it before the checkpoint and close lets a relaunching - /// daemon pass the single-instance guard and start migrating against a - /// file this process is still checkpointing. Under launchd the relaunch - /// is immediate, so that window is real, not theoretical. - /// - /// Same self-scanning idiom (and the same truncate-at-the-test-module trap) - /// as the test above. - #[test] - fn shutdown_awaits_the_sync_watcher_then_checkpoints_then_releases_the_endpoint() { - const SRC: &str = include_str!("main.rs"); - let prod = SRC - .split_once("\n#[cfg(test)]") - .map_or(SRC, |(before, _)| before); - - let await_pos = prod - .find("WATCHER_DRAIN_TIMEOUT, sync_watcher)") - .expect("shutdown must await the PM sync watcher's JoinHandle"); - let checkpoint_pos = prod - .find("meridian::db::meridian::checkpoint_wal(&meridian).await") - .expect("shutdown must checkpoint the WAL"); - let release_pos = prod - .find("meridian::platform::release_endpoint();") - .expect("shutdown must release the single-instance endpoint"); - - assert!( - await_pos < checkpoint_pos, - "the PM sync watcher must be awaited BEFORE the WAL checkpoint — \ - checkpointing underneath its live writes is what corrupted the \ - shared WAL index. Found await at byte {await_pos}, checkpoint at \ - byte {checkpoint_pos}." - ); - assert!( - checkpoint_pos < release_pos, - "release_endpoint() must run AFTER the checkpoint and pool close — \ - releasing it earlier opens the single-instance guard while this \ - process is still writing, letting a relaunching daemon migrate \ - against the same file. Found checkpoint at byte {checkpoint_pos}, \ - release at byte {release_pos}." - ); - } } diff --git a/src/migrations/083_pm_sync_request_seq.sql b/src/migrations/083_pm_sync_request_seq.sql deleted file mode 100644 index 5c3cc7e76..000000000 --- a/src/migrations/083_pm_sync_request_seq.sql +++ /dev/null @@ -1,35 +0,0 @@ --- ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity - --- Give each PM sync request a monotonic sequence number, so a producer can tell --- WHICH request an outcome belongs to. --- --- Migration 082 modelled the outbox as one row per provider whose completion was --- signalled by `completed_at` going non-NULL, and `request()` cleared it so the row --- "unambiguously represents work still to do". With one waiter that is fine. With --- two it loses answers, and the tracker-connect flow always produces at least two: --- `oauth_connected`, `token_connected` and the user's own "Sync now" all fire inside --- a few seconds. --- --- Measured on 1.91.0-staging.2: request A is claimed and a real Jira sync starts; --- request B lands mid-flight and nulls `claimed_at`/`completed_at`; the daemon --- finishes and calls `complete()`, whose guard was `claimed_at IS NOT NULL` - now --- NULL - so the outcome was DISCARDED and the sync re-run from scratch. Every waiter --- then polled for its full 30 s budget and reported failure for a sync that had --- actually succeeded, repeatedly. --- --- With a sequence, "done" is a watermark rather than a flag: a producer holding seq --- N is satisfied by any `completed_seq >= N`, so overlapping requests coalesce --- instead of cannibalising each other, and no completion can be misattributed to a --- request that was never serviced. --- --- `seq` starts at 1 and `completed_seq` is NULL-means-nothing-completed, so --- "pending" is `seq > COALESCE(completed_seq, 0)` everywhere. -ALTER TABLE pm_sync_requests ADD COLUMN seq INTEGER NOT NULL DEFAULT 1; -ALTER TABLE pm_sync_requests ADD COLUMN completed_seq INTEGER; - --- Carry the existing row's state across rather than resetting it. A row already --- completed under 082 must NOT read as pending after this migration (that would fire --- a spurious provider sync on the first daemon start after an update, for every --- installed user at once); a row mid-flight must stay pending so it is still --- serviced. -UPDATE pm_sync_requests SET completed_seq = seq WHERE completed_at IS NOT NULL; diff --git a/tray/src-tauri/src/commands/daemon.rs b/tray/src-tauri/src/commands/daemon.rs index fef8680f5..83afe313b 100644 --- a/tray/src-tauri/src/commands/daemon.rs +++ b/tray/src-tauri/src/commands/daemon.rs @@ -248,14 +248,6 @@ pub(crate) async fn reload_daemon_with( /// point in time this function does not need to reason about. async fn reload_with_pool_cycle(pool: &crate::db_pool::DbPool) -> Result { with_reload_lock(|| async { - // Also excludes a concurrent `DbPool::recover_if_corrupt`, which closes and - // reopens this same handle. `with_reload_lock` alone only serialises reloads - // against each other, so without this a recycle could REOPEN the pool in the - // window between the close below and the restart signal - leaving a live - // connection spanning two daemon generations, which is the 2026-08-24 - // corruption profile this whole close/reopen dance exists to prevent. See - // `DbPool::lock_cycle`. - let _cycle = pool.lock_cycle().await; pool.close().await; let result = super::daemon_control::reload().await; pool.reopen().await; @@ -909,44 +901,4 @@ mod tests { "a later reload still reaches the daemon" ); } - - /// Every close/reopen of the tray's pool must hold `DbPool::lock_cycle`, and - /// `reload_with_pool_cycle` must take it BEFORE it closes. - /// - /// `with_reload_lock` only serialises reloads against each other. - /// `DbPool::recover_if_corrupt` is a second close/reopen site, so without a - /// shared lock the two interleave: - /// - /// 1. reload closes - the handle is `None`; - /// 2. a recycle sees `None`, treats it as nothing to close, and REOPENS; - /// 3. reload signals the daemon restart, with that fresh pool open across it. - /// - /// Step 3 is the 2026-08-24 corruption profile - a tray connection spanning two - /// daemon generations - i.e. the self-heal would have reintroduced the very bug - /// this close/reopen dance exists to prevent. Source-scanned because reproducing - /// it needs a real launchd daemon restart racing a real corrupt write. - #[test] - fn the_reload_cycle_holds_the_shared_pool_lock_before_closing() { - const SRC: &str = include_str!("daemon.rs"); - let prod = SRC - .split_once("\n#[cfg(test)]") - .map_or(SRC, |(before, _)| before); - let body = prod - .split_once("async fn reload_with_pool_cycle") - .expect("reload_with_pool_cycle must exist") - .1; - - let lock_pos = body - .find("lock_cycle().await") - .expect("reload_with_pool_cycle must acquire DbPool::lock_cycle"); - let close_pos = body - .find("pool.close().await") - .expect("reload_with_pool_cycle must close the pool"); - assert!( - lock_pos < close_pos, - "the shared cycle lock must be held BEFORE the close - taking it after \ - leaves the window where a concurrent recycle can reopen the pool into \ - the daemon restart. lock at {lock_pos}, close at {close_pos}." - ); - } } diff --git a/tray/src-tauri/src/commands/integrations.rs b/tray/src-tauri/src/commands/integrations.rs index 0d10f792c..2e6e1d502 100644 --- a/tray/src-tauri/src/commands/integrations.rs +++ b/tray/src-tauri/src/commands/integrations.rs @@ -684,10 +684,7 @@ pub async fn save_integration_token( // First-time (or credential-change) connect — force a sync so the board // populates immediately rather than waiting for the next on-demand trigger. // There's no stale cache to protect here, so gating buys nothing. - crate::commands::tasks::trigger_background_pm_force_sync( - db_pool.inner().clone(), - "token_connected", - ); + crate::commands::tasks::trigger_background_pm_force_sync(db_pool.get(), "token_connected"); Ok(serde_json::json!({ "ok": true, "reloaded": reloaded })) } @@ -1206,7 +1203,7 @@ pub async fn start_oauth( db_pool: State<'_, crate::db_pool::DbPool>, ) -> Result { match body.provider.as_str() { - "jira" | "trello" => start_oauth_in_process(body.provider, db_pool.inner().clone()), + "jira" | "trello" => start_oauth_in_process(body.provider, db_pool.get()), "github" => start_oauth_github_device(body.provider, db_pool.inner().clone()).await, other => Err(format!("Unknown provider: {other}")), } @@ -1261,7 +1258,7 @@ pub async fn cancel_oauth(body: CancelOAuthBody) -> Result<(), String> { /// [`AtomicBool`] prevents two flows from racing to bind the same loopback port. fn start_oauth_in_process( provider: String, - db: crate::db_pool::DbPool, + db: Option, ) -> Result { // Resolve credentials from .env WITHOUT mutating process env. let mode = crate::install::detect_install_mode(); @@ -1473,12 +1470,9 @@ async fn start_oauth_github_device( return; } tracing::info!("GitHub device-flow login succeeded"); - // Clone the HANDLE (not `db_pool.get()`) before `db_pool` is moved - // into the reload below. The old code took a pool here, which the - // reload's `DbPool::close` then killed - so the sync request it was - // saved for could never be written. The handle survives, and - // `request_sync` resolves it after the reopen. - let sync_db = db_pool.clone(); + // Grab the DB handle BEFORE `db_pool` is moved into the reload below, + // so the sync request can still be written afterwards. + let sync_db = db_pool.get(); // Best-effort reload so the token takes effect now, not next restart. if let Err(e) = crate::commands::daemon::reload_daemon_with(db_pool).await { tracing::debug!(error = %e, "daemon reload after GitHub connect (non-fatal)"); diff --git a/tray/src-tauri/src/commands/pause.rs b/tray/src-tauri/src/commands/pause.rs index 7a01d12e4..7d81ccc17 100644 --- a/tray/src-tauri/src/commands/pause.rs +++ b/tray/src-tauri/src/commands/pause.rs @@ -338,13 +338,9 @@ pub(crate) async fn resume_capture( } } - // Restart the capture engine so screen recording resumes. Resolve the managed - // HANDLE rather than passing `pool` (a snapshot this fn was handed): the capture - // consumers outlive any single pool generation - see `start_capture`'s doc. + // Restart the capture engine so screen recording resumes. #[cfg(feature = "capture")] - if let Some(db) = crate::db_pool::from_app(app) { - crate::start_capture(state.clone(), db); - } + crate::start_capture(state.clone(), pool.cloned()); // Emit immediately so the popover reverts to the picker without waiting for the next tick. if let Ok(s) = state.lock() { diff --git a/tray/src-tauri/src/commands/tasks/mod.rs b/tray/src-tauri/src/commands/tasks.rs similarity index 54% rename from tray/src-tauri/src/commands/tasks/mod.rs rename to tray/src-tauri/src/commands/tasks.rs index 76a429d71..f626c7bae 100644 --- a/tray/src-tauri/src/commands/tasks/mod.rs +++ b/tray/src-tauri/src/commands/tasks.rs @@ -81,44 +81,14 @@ pub struct SyncResult { /// tighter — a faster poll would just burn reads without seeing a result any sooner. const OUTCOME_POLL_INTERVAL: Duration = Duration::from_millis(500); -/// Shown when an outbox query fails because `meridian.db` itself is damaged. Points -/// at the banner rather than repeating the SQLite text, because -/// [`explain_outbox_failure`] has just raised that banner and it carries both the -/// full cause and a Repair button - a settings panel has neither. -const DB_DAMAGED_MESSAGE: &str = - "Meridian's database is damaged - use the Repair Database banner on the dashboard"; - -/// Shown during the update window described on [`explain_outbox_failure`]. -const UPDATE_IN_PROGRESS_MESSAGE: &str = - "Meridian is still finishing an update - try again in a moment"; - -/// What a failed `pm_sync_requests` query means for the user, plus the side effect it -/// must trigger first. -/// -/// Both halves of the handoff - the request write and the outcome read - fail for the -/// same three reasons, so they classify here instead of each growing its own ladder. -/// Callers render and log the chain themselves via [`crate::cmd_err!`] and pass the -/// result in as `rendered`, which keeps each site's log message a constant (better -/// grouping in OpenObserve than one message with the operation interpolated into it). -/// -/// # Corruption must reach the banner, not a settings panel -/// -/// This is the branch the whole function exists for. A staging machine's -/// `meridian.db` had real b-tree damage and these two queries were the ONLY code on -/// it to find out: `repair_boot`'s startup probe is skipped while a daemon answers, -/// the daemon latches only when its own queries reach a damaged page, and -/// `poll::refresh` covers only its four dashboard reads. So the user was shown -/// `could not queue the sync: ... database disk image is malformed` inside Settings, -/// with no banner and no Repair button - a recoverable fault presented as a failed -/// button press. [`crate::db_pool::raise_if_corrupt`] fixes that at the source; this -/// function just has to call it and then say something better than the SQL. +/// Turn a failed request-write into something a user can act on. /// /// # Why the missing-table case is special-cased /// /// `pm_sync_requests` arrives in migration 082, and **only the daemon runs migrations** /// (the tray opens the file with `create_if_missing(false)` and assumes the daemon made -/// it). So during an app update there is a window - new tray already running, daemon not -/// yet restarted onto the new binary - where the table genuinely does not exist yet. +/// it). So during an app update there is a window — new tray already running, daemon not +/// yet restarted onto the new binary — where the table genuinely does not exist yet. /// /// It is seconds long and self-heals the moment the daemon restarts, but a user who /// presses "Sync now" inside it would otherwise be shown a raw SQL string: @@ -130,67 +100,24 @@ const UPDATE_IN_PROGRESS_MESSAGE: &str = /// `Database` error whose only distinguishing feature IS its text; the match is /// deliberately loose (table name plus "no such table") so a reworded sqlite message /// degrades to the generic branch rather than mis-reporting something else. -/// Corruption, by contrast, is classified by `is_corrupt_error` on the real error - -/// never by string matching - so it stays correct across sqlite wordings. -async fn explain_outbox_failure( - db: &SqlitePool, - e: &anyhow::Error, - rendered: &str, - fallback: &str, -) -> String { - crate::db_pool::raise_if_corrupt(db, e).await; - - if meridian::db::integrity::is_corrupt_error(e) { - return DB_DAMAGED_MESSAGE.to_string(); - } - // "no such table" (082 not applied) OR "no such column" (083 not applied). The - // column case is not hypothetical: migration 083 added `seq`/`completed_seq`, and - // during an update the new tray queries them before the daemon has migrated. The - // first version of this branch only matched the table and would have shown every - // updating user `no such column: seq` - the exact raw-SQL-in-a-settings-panel - // failure it was written to prevent, one migration later. Any future migration - // touching this table inherits the same window, which is why this matches the - // schema-mismatch FAMILY rather than one message. - // SQLite names the TABLE for a missing table (`no such table: - // pm_sync_requests`) but only the COLUMN for a missing column (`no such column: - // seq`) - and none of this module's `.context(...)` strings contain the literal - // table name, so the two cases need separate needles rather than one - // table-plus-kind check. - // - // Matched on the full `no such column: ` prefix, not on the bare column - // name: `rendered.contains("seq")` would fire on any message containing - // "sequence" or "consequently" and mis-report an unrelated fault as a pending - // update, which sends the user to wait out an update that already finished. - const SCHEMA_PENDING: [&str; 3] = [ - "no such table: pm_sync_requests", - "no such column: seq", - "no such column: completed_seq", - ]; - if SCHEMA_PENDING - .iter() - .any(|needle| rendered.contains(needle)) - { - return UPDATE_IN_PROGRESS_MESSAGE.to_string(); +fn queue_failure_message(e: &anyhow::Error) -> String { + let detail = format!("{e:#}"); + if detail.contains("no such table") && detail.contains("pm_sync_requests") { + tracing::warn!("pm_sync_requests missing - the daemon has not applied migration 082 yet"); + return "Meridian is still finishing an update - try again in a moment".to_string(); } - format!("{fallback}: {rendered}") + tracing::warn!(error = %detail, "could not queue a PM sync request"); + format!("could not queue the sync: {detail}") } -/// Confirm a pool is currently open, without keeping the one we looked at. -/// -/// Returns the HANDLE, not a `SqlitePool`. It used to return the pool, which every -/// caller then held for the length of a 30 s poll loop - so a recycle or a daemon -/// reload part-way through left the rest of that loop querying a dead pool. Callers -/// resolve `get()` per use instead; this only answers "is there any point starting". -/// -/// `None` means the pool is closed (a repair, or a recycle in progress), which is a -/// real condition rather than a bug - say so plainly instead of unwrapping. +/// Resolve the tray's DB handle, or an error string suitable for returning straight +/// to the frontend. `None` means the pool is closed (a repair or a corrupt DB), which +/// is a real condition rather than a bug — say so plainly instead of unwrapping. fn require_pool( pool: &tauri::State<'_, crate::db_pool::DbPool>, -) -> Result { - if pool.get().is_none() { - return Err("the database is not open - Meridian may be repairing it".to_string()); - } - Ok(pool.inner().clone()) +) -> Result { + pool.get() + .ok_or_else(|| "the database is not open - Meridian may be repairing it".to_string()) } /// Re-sync the board from the tracker (the ported /api/tasks/sync POST) — always @@ -288,14 +215,9 @@ pub async fn request_gated_sync_tasks( /// writer to race for the rotating credential - the tray still never holds a token /// itself. Without it, a queued row would sit unserviced and the user would watch a /// spinner time out with the daemon stopped. -/// Takes the `DbPool` HANDLE and resolves it per query, rather than holding one -/// `SqlitePool` for the whole 30 s wait. That matters here specifically: a daemon -/// reload or a `recover_if_corrupt` recycle part-way through the poll loop replaces -/// the pool, and a cached one would spend the rest of the budget querying a closed -/// handle and then report a timeout for a sync that had finished. #[tracing::instrument(skip(db), fields(mode = mode.as_str()))] async fn ask_daemon_to_sync( - db: &crate::db_pool::DbPool, + db: &SqlitePool, mode: SyncMode, reason: &'static str, fallback_cli: &'static str, @@ -314,65 +236,22 @@ async fn ask_daemon_to_sync( })); } - // The sequence number this request was given. Polling the outcome WITHOUT it is - // what made "Sync now" report failure for syncs that had succeeded: the connect - // flow writes several requests seconds apart, and the old row-level - // `completed_at` flag could not say which one an outcome belonged to. - // - // Attempted twice: a first failure whose cause is a broken pool VIEW (not damaged - // data) is recovered by `recover_if_corrupt` recycling the connections, and the - // retry then succeeds - so the user's button works instead of them having to - // discover that quitting and relaunching the app is the cure. Exactly two - // attempts: the recycle either fixed it or the fault is real, and a loop here - // would hold a user-facing command open against a database that cannot serve it. - let mut attempt = 0; - let seq = loop { - attempt += 1; - let pool = db - .get() - .ok_or_else(|| "the database is not open - Meridian may be repairing it".to_string())?; - match pm_sync_requests::request(&pool, ALL_PROVIDERS, mode, reason).await { - Ok(seq) => break seq, - Err(e) => { - let rendered = crate::cmd_err!(e, "could not queue a PM sync request"); - // Raises the banner and recycles the pool when the fault is a broken - // view; returns whether a retry is worth making. - let recovered = db.recover_if_corrupt(&e).await; - crate::db_pool::raise_if_corrupt(&pool, &e).await; - if recovered && attempt == 1 { - tracing::info!("retrying the PM sync request on the recycled pool"); - continue; - } - return Err(explain_outbox_failure( - &pool, - &e, - &rendered, - "could not queue the sync", - ) - .await); - } - } - }; + if let Err(e) = pm_sync_requests::request(db, ALL_PROVIDERS, mode, reason).await { + return Err(queue_failure_message(&e)); + } let deadline = tokio::time::Instant::now() + SYNC_TIMEOUT; loop { if tokio::time::Instant::now() >= deadline { tracing::warn!( timeout_s = SYNC_TIMEOUT.as_secs() as i64, - seq, "daemon did not report a sync outcome in time" ); return Ok(None); } tokio::time::sleep(OUTCOME_POLL_INTERVAL).await; - let Some(pool) = db.get() else { - // A recycle or a daemon reload has the pool closed right now. Keep - // waiting rather than failing - the request row is already written and - // the daemon will service it. - continue; - }; - match pm_sync_requests::outcome(&pool, ALL_PROVIDERS, seq).await { + match pm_sync_requests::outcome(db, ALL_PROVIDERS).await { Ok(Some(out)) => { if let Some(err) = out.error { tracing::warn!(error = %err, "daemon reported a sync failure"); @@ -386,30 +265,7 @@ async fn ask_daemon_to_sync( return Ok(Some(SyncResult { ok: true, detail })); } Ok(None) => continue, - // `cmd_err!`, never a bare `{e}`: every `pm_sync_requests` query adds its - // own `.context(...)`, and `anyhow`'s `Display` renders ONLY the outermost - // one. This site shipped as `could not read the sync outcome: reading the - // PM sync outcome` on a machine whose database was corrupt - the context - // twice over and the actual `(code: 11) database disk image is malformed` - // nowhere, which is precisely the 1.83.2 field incident `cmd_err!` was - // written for. - Err(e) => { - let rendered = crate::cmd_err!(e, "could not read the PM sync outcome"); - // Recycle on a broken view here too, then keep waiting rather than - // failing: the request row is written and the daemon is servicing it, - // so a healed pool on the next poll turn still reports a real result. - if db.recover_if_corrupt(&e).await { - tracing::info!("recycled the pool mid-wait - continuing to poll"); - continue; - } - return Err(explain_outbox_failure( - &pool, - &e, - &rendered, - "could not read the sync outcome", - ) - .await); - } + Err(e) => return Err(format!("could not read the sync outcome: {e}")), } } } @@ -424,7 +280,7 @@ async fn ask_daemon_to_sync( /// is not evidence anyone is about to make a decision from the whole board. The two /// screens that genuinely are — the daily plan and the retarget ticket picker — ask /// for themselves through [`request_gated_sync_tasks`]. See that command's doc. -pub(crate) fn trigger_background_pm_force_sync(db: crate::db_pool::DbPool, reason: &'static str) { +pub(crate) fn trigger_background_pm_force_sync(db: Option, reason: &'static str) { request_sync(db, SyncMode::Force, reason); } @@ -435,45 +291,74 @@ pub(crate) fn trigger_background_pm_force_sync(db: crate::db_pool::DbPool, reaso /// result, so a queued row that the next daemon start services is the right outcome - /// spawning a process per window open is exactly the cost this replaced. /// -/// Takes the [`crate::db_pool::DbPool`] HANDLE and resolves it **inside** the spawned -/// task, not at the call site. +/// Takes `Option` (i.e. `DbPool::get()`) rather than a pool or an +/// `AppHandle`, because `None` is a real state and not an error: the pool is closed +/// while a corrupt DB is being repaired. Callers pass what they already hold, which +/// is a `DbPool` in the integration paths and app state in the window paths. /// -/// This parameter used to be `Option`, and the reasoning was that `None` -/// is a real state (the pool is closed while a corrupt DB is repaired) so callers -/// should pass what they already hold. The state check was right; taking a pool to do -/// it was not. Every caller is a connect-success path, and those paths **restart the -/// daemon**, which calls `DbPool::close`. A pool resolved before that point is dead by -/// the time this task runs - `integrations.rs` even had a comment explaining that it -/// grabbed the pool early "so the sync request can still be written afterwards", -/// which is precisely backwards. Resolving after the spawn means the task sees -/// whichever generation is live when it actually writes. -/// -/// Best-effort by design: these fire from connect-success paths where a failure must -/// never block the thing the user asked for, and the next trigger (or their explicit -/// "Sync now") retries anyway. A corrupt database is the one exception worth -/// surfacing, so it still raises the banner. -fn request_sync(db: crate::db_pool::DbPool, mode: SyncMode, reason: &'static str) { +/// Best-effort by design: these fire from window-open and connect-success paths +/// where a failure must never block the thing the user asked for, and the next +/// trigger (or their explicit "Sync now") retries anyway. +fn request_sync(db: Option, mode: SyncMode, reason: &'static str) { + let Some(db) = db else { + tracing::debug!(reason, "pm sync request skipped - database not open"); + return; + }; tauri::async_runtime::spawn(async move { - let Some(pool) = db.get() else { - tracing::debug!(reason, "pm sync request skipped - database not open"); - return; - }; - match pm_sync_requests::request(&pool, ALL_PROVIDERS, mode, reason).await { - // Nothing waits on this one, so the sequence number is discarded. - Ok(_seq) => tracing::debug!(reason, mode = mode.as_str(), "pm sync requested"), - Err(e) => { - // Full chain: `anyhow`'s `Display` would render only - // `"writing a PM sync request"` and drop the SQLite code under it. - tracing::debug!( - reason, - error = %meridian::errors::chain(&e), - "pm sync request failed" - ); - crate::db_pool::raise_if_corrupt(&pool, &e).await; - } + match pm_sync_requests::request(&db, ALL_PROVIDERS, mode, reason).await { + Ok(()) => tracing::debug!(reason, mode = mode.as_str(), "pm sync requested"), + Err(e) => tracing::debug!(reason, error = %e, "pm sync request failed"), } }); } #[cfg(test)] -mod tests; +mod tests { + use super::*; + + /// The update window: `pm_sync_requests` does not exist yet because the daemon + /// has not applied migration 082. The user must see a transient-update message, + /// never a raw SQL string that reads like database damage. + #[test] + fn a_missing_requests_table_reads_as_a_pending_update() { + let e = anyhow::anyhow!( + "error returned from database: (code: 1) no such table: pm_sync_requests" + ); + + let msg = queue_failure_message(&e); + + assert_eq!( + msg, + "Meridian is still finishing an update - try again in a moment" + ); + assert!(!msg.contains("no such table"), "must not leak SQL: {msg}"); + } + + /// Any OTHER write failure keeps its detail. Collapsing every error into the + /// friendly update message would hide a real fault (a locked or corrupt DB) behind + /// "try again in a moment", which never resolves. + #[test] + fn other_failures_keep_their_detail() { + let e = anyhow::anyhow!("database is locked"); + + let msg = queue_failure_message(&e); + + assert!( + msg.contains("database is locked"), + "detail was dropped: {msg}" + ); + } + + /// A missing table that is NOT ours is somebody else's problem and must not be + /// reported as a pending update - that would send the user to wait out an update + /// that is already finished while the real fault goes unnamed. + #[test] + fn a_different_missing_table_is_not_reported_as_an_update() { + let e = anyhow::anyhow!("no such table: pm_tasks"); + + let msg = queue_failure_message(&e); + + assert!(msg.contains("pm_tasks"), "detail was dropped: {msg}"); + assert!(!msg.contains("finishing an update"), "misattributed: {msg}"); + } +} diff --git a/tray/src-tauri/src/commands/tasks/tests.rs b/tray/src-tauri/src/commands/tasks/tests.rs deleted file mode 100644 index ba3000ace..000000000 --- a/tray/src-tauri/src/commands/tasks/tests.rs +++ /dev/null @@ -1,162 +0,0 @@ -//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity -//! Unit tests for [`super`] — split out only to keep both files under the -//! repo's 500-line cap, following the same `{mod,tests}.rs` shape as -//! `meridian-core/src/pm_sync_requests/` and -//! `src/intelligence/providers/jira/`. - -use super::*; -async fn fresh_db() -> SqlitePool { - use sqlx::sqlite::SqliteConnectOptions; - use std::str::FromStr; - let opts = SqliteConnectOptions::from_str("sqlite::memory:") - .unwrap() - .create_if_missing(true); - let pool = SqlitePool::connect_with(opts).await.unwrap(); - sqlx::migrate!("../../src/migrations") - .run(&pool) - .await - .unwrap(); - pool -} - -/// Render an error the way the real call sites do, so the tests exercise the same -/// `rendered` string production sees rather than a hand-written one. -fn rendered(e: &anyhow::Error) -> String { - format!("{e:#}") -} - -async fn corrupt_notices(pool: &SqlitePool) -> i64 { - sqlx::query_scalar("SELECT COUNT(*) FROM system_notices WHERE notice_id = ?") - .bind(meridian::notices::DB_CORRUPT) - .fetch_one(pool) - .await - .unwrap() -} - -/// The update window: `pm_sync_requests` does not exist yet because the daemon -/// has not applied migration 082. The user must see a transient-update message, -/// never a raw SQL string that reads like database damage. -#[tokio::test] -async fn a_missing_requests_table_reads_as_a_pending_update() { - let pool = fresh_db().await; - let e = - anyhow::anyhow!("error returned from database: (code: 1) no such table: pm_sync_requests"); - - let msg = explain_outbox_failure(&pool, &e, &rendered(&e), "could not queue the sync").await; - - assert_eq!(msg, UPDATE_IN_PROGRESS_MESSAGE); - assert!(!msg.contains("no such table"), "must not leak SQL: {msg}"); - assert_eq!( - corrupt_notices(&pool).await, - 0, - "a pending migration is not corruption" - ); -} - -/// The regression this function was rewritten for. A corrupt database must (a) get -/// the `db.corrupt` banner raised, which is the only surface carrying a Repair -/// button, and (b) send the user there rather than printing SQLite's wording into -/// a settings panel that can do nothing about it. -#[tokio::test] -async fn corruption_raises_the_banner_and_points_at_it() { - let pool = fresh_db().await; - let e = anyhow::anyhow!( - "error returned from database: (code: 11) database disk image is malformed" - ) - .context("reading the PM sync outcome"); - - let msg = - explain_outbox_failure(&pool, &e, &rendered(&e), "could not read the sync outcome").await; - - assert_eq!(msg, DB_DAMAGED_MESSAGE); - assert_eq!( - corrupt_notices(&pool).await, - 1, - "corruption found by an outbox query must raise the same banner the daemon raises" - ); - assert!( - !msg.contains("malformed"), - "the banner carries the cause; the panel should not repeat it: {msg}" - ); -} - -/// The banner's detail must carry the real cause even though the panel message -/// does not - otherwise the diagnosis is lost exactly like the bare-`{e}` bug that -/// hid this incident in the first place. -#[tokio::test] -async fn the_banner_keeps_the_full_cause_chain() { - let pool = fresh_db().await; - let e = anyhow::anyhow!( - "error returned from database: (code: 11) database disk image is malformed" - ) - .context("reading the PM sync outcome"); - - explain_outbox_failure(&pool, &e, &rendered(&e), "could not read the sync outcome").await; - - let detail: String = - sqlx::query_scalar("SELECT detail FROM system_notices WHERE notice_id = ?") - .bind(meridian::notices::DB_CORRUPT) - .fetch_one(&pool) - .await - .unwrap(); - assert!( - detail.contains("database disk image is malformed"), - "banner dropped the cause: {detail}" - ); - assert!( - detail.contains("reading the PM sync outcome"), - "banner dropped the context: {detail}" - ); -} - -/// Any OTHER failure keeps its detail. Collapsing every error into a friendly -/// message would hide a real fault behind "try again in a moment", which never -/// resolves. It must also NOT raise the corruption banner - that would train the -/// user to run `db repair` for faults it cannot fix. -#[tokio::test] -async fn other_failures_keep_their_detail() { - let pool = fresh_db().await; - let e = anyhow::anyhow!("database is locked"); - - let msg = explain_outbox_failure(&pool, &e, &rendered(&e), "could not queue the sync").await; - - assert!( - msg.contains("database is locked"), - "detail was dropped: {msg}" - ); - assert!(msg.starts_with("could not queue the sync"), "{msg}"); - assert_eq!(corrupt_notices(&pool).await, 0, "a lock is not corruption"); -} - -/// A missing table that is NOT ours is somebody else's problem and must not be -/// reported as a pending update - that would send the user to wait out an update -/// that is already finished while the real fault goes unnamed. -#[tokio::test] -async fn a_different_missing_table_is_not_reported_as_an_update() { - let pool = fresh_db().await; - let e = anyhow::anyhow!("no such table: pm_tasks"); - - let msg = explain_outbox_failure(&pool, &e, &rendered(&e), "could not queue the sync").await; - - assert!(msg.contains("pm_tasks"), "detail was dropped: {msg}"); - assert!(!msg.contains("finishing an update"), "misattributed: {msg}"); -} - -/// The two call sites differ only in their fallback phrasing, and that phrasing is -/// what the user reads when nothing more specific applies. Pinned so a refactor -/// cannot silently make the outcome-read failure claim the write failed. -#[tokio::test] -async fn the_fallback_names_the_operation_that_actually_failed() { - let pool = fresh_db().await; - let e = anyhow::anyhow!("disk I/O error"); - - let read = - explain_outbox_failure(&pool, &e, &rendered(&e), "could not read the sync outcome").await; - let write = explain_outbox_failure(&pool, &e, &rendered(&e), "could not queue the sync").await; - - assert!( - read.starts_with("could not read the sync outcome"), - "{read}" - ); - assert!(write.starts_with("could not queue the sync"), "{write}"); -} diff --git a/tray/src-tauri/src/db_pool.rs b/tray/src-tauri/src/db_pool.rs new file mode 100644 index 000000000..3a2aef329 --- /dev/null +++ b/tray/src-tauri/src/db_pool.rs @@ -0,0 +1,209 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +//! The tray's swappable `meridian.db` pool handle. +//! +//! Before this module, `meridian.db`'s pool was opened once at tray startup +//! (`lib.rs`'s `open_existing_lazy` call) and handed to Tauri as bare +//! `Option>` managed state - held for the tray +//! process's ENTIRE lifetime, including across a daemon restart. +//! `commands::daemon::reload_daemon` SIGHUPs the daemon (macOS: exits and +//! relies on launchd, or in a dev session a human, to relaunch it) without the +//! tray's own connection ever knowing a restart happened - so the tray's pool +//! spans two different daemon process generations on the same file. That is +//! the confirmed trigger of a real `meridian.db` corruption incident +//! (2026-08-24): see PR #856, which fixed the daemon's shutdown to checkpoint +//! the WAL and made the tray's own reads detect corruption immediately - both +//! good independent hardening, but neither closes the actual gap. +//! +//! [`DbPool`] closes it: `reload_daemon` calls [`DbPool::close`] before +//! signaling and [`DbPool::reopen`] once the new daemon process is confirmed +//! up, so the tray never holds a connection spanning the boundary. Every +//! other call site is unaffected - [`DbPool::get`] returns the exact same +//! `Option` shape `State>>::inner()` used to, +//! just renamed, since "the pool might legitimately be absent right now" was +//! already part of every caller's contract (a `None` during a first launch, +//! before the daemon has created the file). +//! +//! # Who calls this +//! - `lib.rs`'s setup hook constructs it and calls `app.manage`. +//! - `commands::daemon::reload_daemon` calls `close`/`reopen` around the +//! signal - the one thing that could not be done through the old bare +//! `Option>` state. +//! - Every dashboard/poll read that used to do +//! `let Some(pool) = pool.inner() else { ... }` now does the same against +//! `pool.get()`. + +use meridian_core::SqlitePool; +use std::sync::{Arc, RwLock}; + +/// Swappable handle to the tray's `meridian.db` pool, managed as Tauri state +/// in place of a bare `Option>`. +/// +/// `uri`/`key_hex` are remembered at construction so [`reopen`](Self::reopen) +/// needs no arguments at its call site - `reload_daemon` has neither the DB +/// path nor the encryption key on hand, only this handle. +#[derive(Clone)] +pub struct DbPool { + inner: Arc>>, + uri: String, + key_hex: Option, +} + +/// Manual, not derived: several call sites take `DbPool` as a `#[tauri::command]` +/// parameter without `#[tracing::instrument(skip(...))]`, so a derived `Debug` +/// would print `key_hex` — the raw SQLCipher key — into a span every time one +/// of those commands runs. `key_hex.is_some()` is all any log ever needs. +impl std::fmt::Debug for DbPool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DbPool") + .field("uri", &self.uri) + .field("key_set", &self.key_hex.is_some()) + .finish() + } +} + +impl DbPool { + pub fn new(pool: Option, uri: String, key_hex: Option) -> Self { + Self { + inner: Arc::new(RwLock::new(pool)), + uri, + key_hex, + } + } + + /// The pool, if one is currently open - `None` before the daemon has + /// created `meridian.db` yet, or during the brief window between + /// [`close`](Self::close) and [`reopen`](Self::reopen) around a daemon + /// restart. Cheap: `sqlx::SqlitePool` is itself `Arc`-backed, so this is + /// a shallow clone, not a new connection. + pub fn get(&self) -> Option { + self.inner.read().unwrap_or_else(|e| e.into_inner()).clone() + } + + /// Close the pool and clear the handle. Called before signaling the + /// daemon to restart, so nothing on this side keeps a connection alive + /// spanning the old process's shutdown and the new one's startup - see + /// this module's header for the corruption this closes off. Every reader + /// sees `get() == None` for the duration and behaves exactly as it + /// already does on a cold start (empty defaults, no panic). + pub async fn close(&self) { + let taken = self.inner.write().unwrap_or_else(|e| e.into_inner()).take(); + if let Some(pool) = taken { + pool.close().await; + } + } + + /// Reopen against the same uri/key this handle was built with. Lazy, + /// matching the original startup open - see that call site's doc + /// (`lib.rs`) for why eager fails when `meridian.db` briefly does not + /// exist. Best-effort: a failure here is logged and leaves `get()` + /// returning `None`, same as any other reason the pool isn't open yet; + /// the daemon's own re-creation of the file on its next write heals it + /// exactly as a lazy pool always has. + pub async fn reopen(&self) { + match meridian_core::open_existing_lazy(&self.uri, self.key_hex.as_deref()).await { + Ok(pool) => { + *self.inner.write().unwrap_or_else(|e| e.into_inner()) = Some(pool); + } + Err(e) => { + tracing::error!( + error = %e, + "DbPool::reopen failed - meridian.db stays unavailable until the next reload or tray restart" + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn migrated_db(dir: &std::path::Path) -> (String, meridian_core::SqlitePool) { + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use std::str::FromStr; + + let path = dir.join("db_pool_test.db"); + let uri = format!("sqlite://{}", path.display()); + // `open_existing`/`open_existing_lazy` both set `create_if_missing(false)` + // (they assume the daemon already created the file) - a test fixture + // needs its own connect path to create one from scratch. + let opts = SqliteConnectOptions::from_str(&uri) + .unwrap() + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .connect_with(opts) + .await + .expect("create db"); + sqlx::migrate!("../../src/migrations") + .run(&pool) + .await + .expect("migrate"); + pool.close().await; + // Reopen through the same lazy path `DbPool` itself uses, so the + // handle under test behaves exactly like production. + let pool = meridian_core::open_existing_lazy(&uri, None) + .await + .expect("reopen lazily"); + (uri, pool) + } + + /// `get()` must return exactly what was passed to `new()`. + #[tokio::test] + async fn get_returns_the_pool_it_was_built_with() { + let dir = tempfile::tempdir().unwrap(); + let (uri, pool) = migrated_db(dir.path()).await; + let handle = DbPool::new(Some(pool), uri, None); + assert!(handle.get().is_some()); + } + + /// A handle built with no pool (e.g. the DB isn't open yet) must behave + /// exactly like the old `None` state every caller already handles. + #[tokio::test] + async fn get_is_none_when_built_empty() { + let handle = DbPool::new(None, "sqlite://does-not-matter".to_string(), None); + assert!(handle.get().is_none()); + } + + /// The exact sequence `reload_daemon` runs: close, then a read in + /// between must see `None` (nothing races the daemon's restart), then + /// reopen brings a working pool back without needing to be told the URI + /// again. + #[tokio::test] + async fn close_then_reopen_restores_a_working_pool() { + let dir = tempfile::tempdir().unwrap(); + let (uri, pool) = migrated_db(dir.path()).await; + let handle = DbPool::new(Some(pool), uri, None); + + handle.close().await; + assert!( + handle.get().is_none(), + "a reader between close() and reopen() must see no pool, not a stale one" + ); + + handle.reopen().await; + let reopened = handle.get().expect("reopen must restore a pool"); + // Prove it's a genuinely live connection, not just a non-None marker. + meridian_core::ping(&reopened) + .await + .expect("reopened pool must actually work"); + } + + /// `reopen` must not panic on failure, and must leave `get()` at `None` + /// rather than propagating the error - `reopen` is deliberately + /// best-effort (see its doc), the same shape as any other reason a lazy + /// pool isn't open yet. A missing/wrong-shaped file is NOT enough to + /// prove this (`open_existing_lazy` defers that check to first use, so + /// it would return `Ok` here regardless) - an invalid key is what + /// actually fails synchronously, at `validate_key_hex` inside + /// `open_existing_lazy` itself, before any connection is attempted. + #[tokio::test] + async fn reopen_failure_leaves_the_handle_empty_not_panicked() { + let handle = DbPool::new( + None, + "sqlite://does-not-matter".to_string(), + Some("not-valid-hex".to_string()), + ); + handle.reopen().await; + assert!(handle.get().is_none()); + } +} diff --git a/tray/src-tauri/src/db_pool/mod.rs b/tray/src-tauri/src/db_pool/mod.rs deleted file mode 100644 index 2b5f8a0c4..000000000 --- a/tray/src-tauri/src/db_pool/mod.rs +++ /dev/null @@ -1,342 +0,0 @@ -//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity -//! The tray's swappable `meridian.db` pool handle. -//! -//! Before this module, `meridian.db`'s pool was opened once at tray startup -//! (`lib.rs`'s `open_existing_lazy` call) and handed to Tauri as bare -//! `Option>` managed state - held for the tray -//! process's ENTIRE lifetime, including across a daemon restart. -//! `commands::daemon::reload_daemon` SIGHUPs the daemon (macOS: exits and -//! relies on launchd, or in a dev session a human, to relaunch it) without the -//! tray's own connection ever knowing a restart happened - so the tray's pool -//! spans two different daemon process generations on the same file. That is -//! the confirmed trigger of a real `meridian.db` corruption incident -//! (2026-08-24): see PR #856, which fixed the daemon's shutdown to checkpoint -//! the WAL and made the tray's own reads detect corruption immediately - both -//! good independent hardening, but neither closes the actual gap. -//! -//! [`DbPool`] closes it: `reload_daemon` calls [`DbPool::close`] before -//! signaling and [`DbPool::reopen`] once the new daemon process is confirmed -//! up, so the tray never holds a connection spanning the boundary. Every -//! other call site is unaffected - [`DbPool::get`] returns the exact same -//! `Option` shape `State>>::inner()` used to, -//! just renamed, since "the pool might legitimately be absent right now" was -//! already part of every caller's contract (a `None` during a first launch, -//! before the daemon has created the file). -//! -//! # Who calls this -//! - `lib.rs`'s setup hook constructs it and calls `app.manage`. -//! - `commands::daemon::reload_daemon` calls `close`/`reopen` around the -//! signal - the one thing that could not be done through the old bare -//! `Option>` state. -//! - Every dashboard/poll read that used to do -//! `let Some(pool) = pool.inner() else { ... }` now does the same against -//! `pool.get()`. -//! - Anything that touches this pool and can fail calls -//! [`raise_if_corrupt`] on the error - see that function's doc. - -use meridian_core::SqlitePool; -use std::sync::{Arc, RwLock}; -use std::time::Instant; - -/// The managed [`DbPool`] handle, from an app handle. -/// -/// For code that must reach the pool but is not a `#[tauri::command]` (so it -/// cannot take `State<'_, DbPool>`): the capture consumers, the poll loop's -/// guards, `resume_capture`. -/// -/// # Take the HANDLE, never a pool snapshot -/// -/// The point of returning `DbPool` rather than `Option` is that -/// callers must resolve [`DbPool::get`] **at each use**. A long-lived task that -/// clones the `SqlitePool` once and keeps it is the exact bug this exists to -/// prevent: [`close`](DbPool::close) can only reach the pool inside this -/// handle, so an escaped clone keeps its connections - and its WAL-index -/// (`-shm`) mapping - alive across the daemon restart the close/reopen dance -/// exists to fence off. Measured on 1.91.0-staging.2: the capture consumers -/// held such a clone for the whole process lifetime and wrote through it every -/// ~2.5 s, and after a reconnect-triggered daemon restart every write failed -/// with `(code: 11) database disk image is malformed` **while the file itself -/// was healthy** (`db check`: 40 tables clean) and reads kept succeeding. Only -/// writes break, because reads can still be served from the main file while the -/// WAL write path cannot. -pub(crate) fn from_app(app: &tauri::AppHandle) -> Option { - use tauri::Manager; - app.try_state::().map(|s| s.inner().clone()) -} - -/// If `err` indicates `meridian.db` is corrupt, raise the SAME `db.corrupt` -/// notice `main.rs`'s `etl_tick` raises on the daemon side - immediately, -/// from whichever side of the app noticed first. -/// -/// The daemon already had this covered for its own queries, but the tray holds -/// its own independent, long-lived pool on the same file (opened once at -/// startup, `lib.rs`'s `app.manage(db_pool)`) and touches different tables on -/// its own cadence. In the incident this was written for, the tray's poll-loop -/// reads hit `(code: 11) database disk image is malformed` a full 5+ minutes -/// before any daemon-side query happened to touch the same damage - and until -/// this function existed, that whole window was silent `tracing::warn!` noise -/// with no banner, because nothing on this side of the process ever called -/// `raise_typed`. Idempotent (`raise_typed` upserts), so calling it on every -/// failing tick is safe and cheap - it does not need its own latch the way the -/// daemon's ETL loop does, because a tick that keeps failing just keeps -/// refreshing the same notice row rather than retrying a query with side -/// effects. -/// -/// # Why this lives here and not in `poll::refresh` -/// -/// It started as a private helper wrapping that loop's four dashboard READS, -/// which quietly made "the tray noticed corruption" mean "one of four specific -/// reads noticed corruption". `commands::tasks`' PM-sync outbox writes are on -/// this same pool and outside all of it, so on a staging machine whose -/// `meridian.db` was damaged they were the only code to find the damage - and -/// reported it as a raw SQL string in a settings panel, with no banner and no -/// Repair button, because the three detectors that DO know what corruption -/// means each had a scope that excluded them: -/// -/// - `repair_boot`'s startup probe is skipped entirely while a daemon answers -/// (its own comment defers to "the notice banner instead"); -/// - the daemon latches only when ITS queries reach a damaged page; -/// - this helper only covered `poll::refresh`. -/// -/// Living on the pool module is what lets any of them call it - both `poll` and -/// `commands` already depend on this module for `DbPool` itself. -/// -/// # Coverage is still partial - do not read this as an invariant -/// -/// The rule this SHOULD enforce is "a failure on the tray's pool goes through -/// here". It does not yet. Wired today: `poll::refresh`'s four reads and -/// `commands::tasks`' two outbox queries. **Not** wired: `commands::dashboard`, -/// which has ~24 `cmd_err!` sites reading `pm_tasks`, triage, week and -/// coding-agent tables - so damage confined to those pages is still found -/// without raising the banner. -/// -/// That gap is narrower than it looks, because `poll::refresh` re-reads the -/// active-session/today/worklogs tables every ~30 s and the daemon latches on -/// its own ETL path, so most real damage is reached by something that does -/// raise. It is not zero, though, and the honest statement is that this is a -/// convention being adopted rather than one already held everywhere. Anything -/// added here should also be added to those sites rather than assuming they are -/// already covered. -pub(crate) async fn raise_if_corrupt(pool: &SqlitePool, err: &anyhow::Error) { - if !meridian::db::integrity::is_corrupt_error(err) { - return; - } - let _ = meridian::notices::raise_typed( - pool, - meridian::notices::Notice { - id: meridian::notices::DB_CORRUPT, - severity: "error", - title: "Meridian's database is damaged", - // Full chain, not `err.to_string()` - same reasoning as - // `crate::cmd_err!`'s doc comment: `anyhow::Error`'s `Display` - // renders only the outermost `.context()` and would otherwise - // drop the SQLite code a reader needs. - detail: &format!("{err:#}"), - remedy: Some("Quit Meridian, then run 'meridian db repair' in a terminal"), - event_key: meridian::notices::DB_CORRUPT, - deep_link: Some(meridian_core::notifications::deep_links::LOGS), - }, - ) - .await; -} - -/// Swappable handle to the tray's `meridian.db` pool, managed as Tauri state -/// in place of a bare `Option>`. -/// -/// `uri`/`key_hex` are remembered at construction so [`reopen`](Self::reopen) -/// needs no arguments at its call site - `reload_daemon` has neither the DB -/// path nor the encryption key on hand, only this handle. -#[derive(Clone)] -pub struct DbPool { - inner: Arc>>, - uri: String, - key_hex: Option, - /// Serialises [`recover_if_corrupt`](Self::recover_if_corrupt) and remembers - /// when it last ran, so concurrent failing writers recycle the pool once - /// between them instead of each racing their own close/reopen. - recycle: Arc>>, -} - -/// Minimum gap between two pool recycles. -/// -/// Without it, a wedged pool would recycle on EVERY failing write - the capture -/// consumers alone write every ~2.5 s - so a fault the recycle cannot fix (real -/// file damage, a revoked key) would turn into a close/reopen storm on the file -/// the daemon is also using. One attempt per window, then the banner and the -/// error stand. -const RECYCLE_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(30); - -/// Ceiling on the close half of a recycle. -/// -/// `SqlitePool::close` waits for checked-out connections to come back, and the -/// whole reason we are here is that something is wrong with this pool. Bounded so -/// a connection that never returns cannot hold the recycle lock - and therefore -/// every future recovery attempt - forever. `close` clears the handle -/// synchronously before it awaits, so a timeout still leaves `get()` at `None` -/// and the reopen below still installs a fresh pool. -const RECYCLE_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); - -/// Manual, not derived: several call sites take `DbPool` as a `#[tauri::command]` -/// parameter without `#[tracing::instrument(skip(...))]`, so a derived `Debug` -/// would print `key_hex` — the raw SQLCipher key — into a span every time one -/// of those commands runs. `key_hex.is_some()` is all any log ever needs. -impl std::fmt::Debug for DbPool { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("DbPool") - .field("uri", &self.uri) - .field("key_set", &self.key_hex.is_some()) - .finish() - } -} - -impl DbPool { - pub fn new(pool: Option, uri: String, key_hex: Option) -> Self { - Self { - inner: Arc::new(RwLock::new(pool)), - uri, - key_hex, - recycle: Arc::new(tokio::sync::Mutex::new(None)), - } - } - - /// Recover from a write that failed because this pool's view of the database is - /// broken: drop every connection and open a fresh pool. Returns whether the - /// caller now has a working pool and may retry. - /// - /// # Why the app must heal itself here - /// - /// On 1.91.0-staging.2 a reconnect-triggered daemon restart left the tray's - /// connections with a desynced WAL index (`-shm`). Reads kept working, every - /// write failed with `(code: 11) database disk image is malformed`, and `db - /// check` reported all 40 tables healthy - the data was never damaged, only this - /// process's bookkeeping. The only cure was quitting and relaunching the app, - /// which **no user has any way of knowing**. They just saw sync stop working. - /// - /// Closing and reopening the pool is what that relaunch did for the database - /// handle, so doing it here removes the need for the user to be told anything. - /// It is deliberately independent of *why* the view broke: the ordering fixes - /// that shipped alongside this close the mechanism we identified, but this is - /// what makes a mechanism we did NOT identify survivable rather than permanent. - /// - /// Non-corrupt errors return `false` immediately - a locked database or a - /// missing table must not trigger a reconnect. - /// - /// # Caller contract - /// - /// **Call this only after your query has returned**, never while holding a - /// connection from this pool. The close half waits for checked-out connections - /// to be returned, so a caller still holding one would wait on itself. Every - /// current caller is on an error path, where the connection is already back. - /// Exclusive access to this handle's close/reopen lifecycle. - /// - /// **Every close+reopen pair must hold this**, not just the recycle path. - /// `commands::daemon::reload_with_pool_cycle` had its own private lock, which - /// serialised reloads against each other but not against - /// [`recover_if_corrupt`](Self::recover_if_corrupt) - and interleaving those two - /// reintroduces the exact hazard the close/reopen dance exists to remove: - /// - /// 1. reload closes, handle is `None`; - /// 2. a recycle sees `None`, treats it as nothing to close, and REOPENS; - /// 3. reload then signals the daemon restart - with that fresh pool open - /// across it. - /// - /// Step 3 is the 2026-08-24 corruption profile (a tray connection spanning two - /// daemon generations with no WAL checkpoint between them). The lock lives on the - /// handle rather than in either caller so a third close/reopen site cannot be - /// added without one. - /// - /// The guard's value is the last recycle instant, which is also what makes the - /// cooldown check-and-set atomic. - pub(crate) async fn lock_cycle(&self) -> tokio::sync::MutexGuard<'_, Option> { - self.recycle.lock().await - } - - pub(crate) async fn recover_if_corrupt(&self, err: &anyhow::Error) -> bool { - if !meridian::db::integrity::is_corrupt_error(err) { - return false; - } - - // Held across the close/reopen on purpose: it serialises concurrent - // recyclers, excludes a `reload_daemon` cycle (see `lock_cycle`), and makes - // the cooldown check-and-set atomic. - let mut last = self.lock_cycle().await; - if let Some(at) = *last { - if at.elapsed() < RECYCLE_COOLDOWN { - tracing::debug!( - "skipping meridian.db pool recycle - one ran less than {}s ago", - RECYCLE_COOLDOWN.as_secs() - ); - return false; - } - } - *last = Some(Instant::now()); - - tracing::warn!("recycling the meridian.db pool after a corrupt-view write failure"); - if tokio::time::timeout(RECYCLE_CLOSE_TIMEOUT, self.close()) - .await - .is_err() - { - // `close` already took the handle before awaiting, so this is safe to - // proceed through - the old pool is unreachable either way. - tracing::warn!( - timeout_s = RECYCLE_CLOSE_TIMEOUT.as_secs() as i64, - "pool close did not finish during recycle - reopening anyway" - ); - } - self.reopen().await; - - let recovered = self.get().is_some(); - if recovered { - tracing::info!("meridian.db pool recycled - writes should work again"); - } else { - tracing::warn!("meridian.db pool recycle did not yield a usable pool"); - } - recovered - } - - /// The pool, if one is currently open - `None` before the daemon has - /// created `meridian.db` yet, or during the brief window between - /// [`close`](Self::close) and [`reopen`](Self::reopen) around a daemon - /// restart. Cheap: `sqlx::SqlitePool` is itself `Arc`-backed, so this is - /// a shallow clone, not a new connection. - pub fn get(&self) -> Option { - self.inner.read().unwrap_or_else(|e| e.into_inner()).clone() - } - - /// Close the pool and clear the handle. Called before signaling the - /// daemon to restart, so nothing on this side keeps a connection alive - /// spanning the old process's shutdown and the new one's startup - see - /// this module's header for the corruption this closes off. Every reader - /// sees `get() == None` for the duration and behaves exactly as it - /// already does on a cold start (empty defaults, no panic). - pub async fn close(&self) { - let taken = self.inner.write().unwrap_or_else(|e| e.into_inner()).take(); - if let Some(pool) = taken { - pool.close().await; - } - } - - /// Reopen against the same uri/key this handle was built with. Lazy, - /// matching the original startup open - see that call site's doc - /// (`lib.rs`) for why eager fails when `meridian.db` briefly does not - /// exist. Best-effort: a failure here is logged and leaves `get()` - /// returning `None`, same as any other reason the pool isn't open yet; - /// the daemon's own re-creation of the file on its next write heals it - /// exactly as a lazy pool always has. - pub async fn reopen(&self) { - match meridian_core::open_existing_lazy(&self.uri, self.key_hex.as_deref()).await { - Ok(pool) => { - *self.inner.write().unwrap_or_else(|e| e.into_inner()) = Some(pool); - } - Err(e) => { - tracing::error!( - error = %e, - "DbPool::reopen failed - meridian.db stays unavailable until the next reload or tray restart" - ); - } - } - } -} - -#[cfg(test)] -mod tests; diff --git a/tray/src-tauri/src/db_pool/tests.rs b/tray/src-tauri/src/db_pool/tests.rs deleted file mode 100644 index d1b983f87..000000000 --- a/tray/src-tauri/src/db_pool/tests.rs +++ /dev/null @@ -1,276 +0,0 @@ -//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity -//! Tests for the tray's swappable `meridian.db` pool handle. -//! -//! Split out of `mod.rs` for the 500-line file cap, following the same -//! `{mod,tests}.rs` shape as `meridian-core/src/pm_sync_requests/` and -//! `src/intelligence/providers/jira/`. - -use super::*; - -async fn migrated_db(dir: &std::path::Path) -> (String, meridian_core::SqlitePool) { - use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; - use std::str::FromStr; - - let path = dir.join("db_pool_test.db"); - let uri = format!("sqlite://{}", path.display()); - // `open_existing`/`open_existing_lazy` both set `create_if_missing(false)` - // (they assume the daemon already created the file) - a test fixture - // needs its own connect path to create one from scratch. - let opts = SqliteConnectOptions::from_str(&uri) - .unwrap() - .create_if_missing(true); - let pool = SqlitePoolOptions::new() - .connect_with(opts) - .await - .expect("create db"); - sqlx::migrate!("../../src/migrations") - .run(&pool) - .await - .expect("migrate"); - pool.close().await; - // Reopen through the same lazy path `DbPool` itself uses, so the - // handle under test behaves exactly like production. - let pool = meridian_core::open_existing_lazy(&uri, None) - .await - .expect("reopen lazily"); - (uri, pool) -} - -/// `get()` must return exactly what was passed to `new()`. -#[tokio::test] -async fn get_returns_the_pool_it_was_built_with() { - let dir = tempfile::tempdir().unwrap(); - let (uri, pool) = migrated_db(dir.path()).await; - let handle = DbPool::new(Some(pool), uri, None); - assert!(handle.get().is_some()); -} - -/// A handle built with no pool (e.g. the DB isn't open yet) must behave -/// exactly like the old `None` state every caller already handles. -#[tokio::test] -async fn get_is_none_when_built_empty() { - let handle = DbPool::new(None, "sqlite://does-not-matter".to_string(), None); - assert!(handle.get().is_none()); -} - -/// The exact sequence `reload_daemon` runs: close, then a read in -/// between must see `None` (nothing races the daemon's restart), then -/// reopen brings a working pool back without needing to be told the URI -/// again. -#[tokio::test] -async fn close_then_reopen_restores_a_working_pool() { - let dir = tempfile::tempdir().unwrap(); - let (uri, pool) = migrated_db(dir.path()).await; - let handle = DbPool::new(Some(pool), uri, None); - - handle.close().await; - assert!( - handle.get().is_none(), - "a reader between close() and reopen() must see no pool, not a stale one" - ); - - handle.reopen().await; - let reopened = handle.get().expect("reopen must restore a pool"); - // Prove it's a genuinely live connection, not just a non-None marker. - meridian_core::ping(&reopened) - .await - .expect("reopened pool must actually work"); -} - -/// **The regression test for the 1.91.0-staging.2 write wedge.** -/// -/// A pool CLONE taken before `close()` is dead afterwards, while the handle -/// keeps working across the same close/reopen. That is the entire difference -/// between the old capture consumers (which cached -/// `Option` for the process lifetime and wrote through it every -/// ~2.5 s) and the current ones (which call `get()` per write). -/// -/// Asserting on the clone is what makes this a real guard rather than a -/// tautology: it proves the snapshot pattern is *observably* broken by a -/// daemon reload, so re-introducing it anywhere cannot look harmless. -#[tokio::test] -async fn a_pool_snapshot_dies_across_a_reload_but_the_handle_survives() { - let dir = tempfile::tempdir().unwrap(); - let (uri, pool) = migrated_db(dir.path()).await; - let handle = DbPool::new(Some(pool), uri, None); - - // What a long-lived consumer used to cache at startup. - let snapshot = handle.get().expect("a pool to snapshot"); - meridian_core::ping(&snapshot) - .await - .expect("the snapshot works before the reload"); - - // Exactly what `reload_daemon` does around every daemon restart. - handle.close().await; - handle.reopen().await; - - assert!( - meridian_core::ping(&snapshot).await.is_err(), - "a cached SqlitePool must be observably dead after close/reopen - if this \ - ever passes, the snapshot pattern looks safe and the capture wedge returns" - ); - - let fresh = handle.get().expect("the handle must still yield a pool"); - meridian_core::ping(&fresh) - .await - .expect("resolving the handle per use must survive the reload"); -} - -fn corrupt_err() -> anyhow::Error { - anyhow::anyhow!("error returned from database: (code: 11) database disk image is malformed") - .context("writing a PM sync request") -} - -/// The self-heal: a corrupt-VIEW write failure must give the caller a working -/// pool back, with no user action. -/// -/// This is what removes the relaunch. On 1.91.0-staging.2 a wedged pool stayed -/// wedged for the life of the tray process, and quitting the app was the only -/// cure - which no user could be expected to discover. -#[tokio::test] -async fn a_corrupt_write_recycles_the_pool_and_recovers() { - let dir = tempfile::tempdir().unwrap(); - let (uri, pool) = migrated_db(dir.path()).await; - let handle = DbPool::new(Some(pool), uri, None); - let before = handle.get().expect("a pool"); - - assert!( - handle.recover_if_corrupt(&corrupt_err()).await, - "a corrupt write must report that recovery succeeded" - ); - - // The connections that held the broken view are gone... - assert!( - meridian_core::ping(&before).await.is_err(), - "the recycled pool's old connections must be dropped, not reused" - ); - // ...and the caller can retry against a working one. - let after = handle.get().expect("a fresh pool after recycle"); - meridian_core::ping(&after) - .await - .expect("the recycled pool must actually work"); -} - -/// Only corruption may recycle. A locked database, a missing table or a pending -/// migration must leave the pool alone - dropping every connection on an ordinary -/// transient error would turn a blip into an outage. -#[tokio::test] -async fn an_unrelated_error_does_not_recycle_the_pool() { - let dir = tempfile::tempdir().unwrap(); - let (uri, pool) = migrated_db(dir.path()).await; - let handle = DbPool::new(Some(pool), uri, None); - let before = handle.get().expect("a pool"); - - let err = anyhow::anyhow!("database is locked").context("writing a PM sync request"); - assert!(!handle.recover_if_corrupt(&err).await); - - meridian_core::ping(&before) - .await - .expect("an unrelated error must leave the existing pool usable"); -} - -/// The cooldown. Capture writes every ~2.5 s, so a fault the recycle CANNOT fix -/// (real file damage, a revoked key) would otherwise become a close/reopen storm -/// on the file the daemon is also using. -#[tokio::test] -async fn a_second_corrupt_write_inside_the_cooldown_does_not_recycle_again() { - let dir = tempfile::tempdir().unwrap(); - let (uri, pool) = migrated_db(dir.path()).await; - let handle = DbPool::new(Some(pool), uri, None); - - assert!(handle.recover_if_corrupt(&corrupt_err()).await); - let after_first = handle.get().expect("a pool"); - - assert!( - !handle.recover_if_corrupt(&corrupt_err()).await, - "a second failure moments later must be refused, not recycled again" - ); - meridian_core::ping(&after_first) - .await - .expect("the refused attempt must not have torn down the working pool"); -} - -/// In-memory, schema-migrated. Enough for [`raise_if_corrupt`], which only -/// inspects the error it is handed and needs a `system_notices` table to -/// write into - real corrupted bytes on disk are not required, and -/// `sqlite::memory:` cannot be corrupted anyway (see -/// `src/db/test_corrupt.rs` for fixtures that can). -async fn fresh_db() -> SqlitePool { - use sqlx::sqlite::SqliteConnectOptions; - use std::str::FromStr; - let opts = SqliteConnectOptions::from_str("sqlite::memory:") - .unwrap() - .create_if_missing(true); - let pool = SqlitePool::connect_with(opts).await.unwrap(); - sqlx::migrate!("../../src/migrations") - .run(&pool) - .await - .unwrap(); - pool -} - -/// Whichever side of the app touches this pool must raise `db.corrupt` the -/// moment IT hits corruption, not wait for a daemon-side query to stumble -/// onto the same damage minutes later. `db::integrity::is_corrupt_error` -/// (the classifier this delegates to) is already pinned against the real -/// field-incident shape elsewhere. -#[tokio::test] -async fn raise_if_corrupt_writes_the_notice_on_a_corrupt_error() { - let pool = fresh_db().await; - let err = anyhow::anyhow!( - "error returned from database: (code: 11) database disk image is malformed" - ) - .context("current_task: fetch most recent task session"); - - raise_if_corrupt(&pool, &err).await; - - let row: (String, String) = - sqlx::query_as("SELECT severity, detail FROM system_notices WHERE notice_id = ?") - .bind(meridian::notices::DB_CORRUPT) - .fetch_one(&pool) - .await - .expect("db.corrupt notice must be written"); - assert_eq!(row.0, "error"); - assert!( - row.1.contains("database disk image is malformed"), - "notice detail dropped the actual cause: {}", - row.1 - ); -} - -/// Every other failure (a lock, a missing table, a network blip on an -/// unrelated call) must NOT raise the corruption banner — that would train -/// the user to run `db repair` for faults it can't fix. -#[tokio::test] -async fn raise_if_corrupt_is_silent_on_unrelated_errors() { - let pool = fresh_db().await; - let err = anyhow::anyhow!("database is locked").context("today: fetch sessions"); - - raise_if_corrupt(&pool, &err).await; - - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM system_notices WHERE notice_id = ?") - .bind(meridian::notices::DB_CORRUPT) - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(count, 0, "an unrelated error must not raise db.corrupt"); -} - -/// `reopen` must not panic on failure, and must leave `get()` at `None` -/// rather than propagating the error - `reopen` is deliberately -/// best-effort (see its doc), the same shape as any other reason a lazy -/// pool isn't open yet. A missing/wrong-shaped file is NOT enough to -/// prove this (`open_existing_lazy` defers that check to first use, so -/// it would return `Ok` here regardless) - an invalid key is what -/// actually fails synchronously, at `validate_key_hex` inside -/// `open_existing_lazy` itself, before any connection is attempted. -#[tokio::test] -async fn reopen_failure_leaves_the_handle_empty_not_panicked() { - let handle = DbPool::new( - None, - "sqlite://does-not-matter".to_string(), - Some("not-valid-hex".to_string()), - ); - handle.reopen().await; - assert!(handle.get().is_none()); -} diff --git a/tray/src-tauri/src/lib.rs b/tray/src-tauri/src/lib.rs index 7899b0717..670b2e325 100644 --- a/tray/src-tauri/src/lib.rs +++ b/tray/src-tauri/src/lib.rs @@ -936,10 +936,8 @@ pub fn run() { None, )); } - // (No `capture_pool` binding here any more: `start_capture` takes the - // managed `DbPool` handle and resolves it per write, so the raw - // `Option` this used to carry across to it is exactly the - // snapshot that wedged capture writes after a daemon restart.) + #[cfg(feature = "capture")] + let capture_pool = db_setup_result; // Single source of truth for the tray menu lives in `tray.rs`, so the // poll loop's health-driven rebuild can't drift out of sync. Initial @@ -1241,19 +1239,8 @@ pub fn run() { // that task, never the tray (we gave up the screenpipe daemon's process // isolation, so this matters). Frames → capture_frames (slice 4a), // input events → capture_ui_events (slice 3c). - // Pass the managed HANDLE, never `capture_pool` (the raw - // `Option` snapshot from setup) - see `start_capture`'s doc. - // Always `Some` by here: the fallback above registers an empty handle on - // the panic path, so this only skips capture if managed state is somehow - // absent entirely, which nothing else could work through either. #[cfg(feature = "capture")] - if let Some(db) = db_pool::from_app(app.handle()) { - start_capture(app_state.clone(), db); - } else { - tracing::error!( - "capture not started - the DbPool handle is not managed, so nothing could persist frames" - ); - } + start_capture(app_state.clone(), capture_pool); // Auto-open the setup wizard on first launch (no ~/.meridian/onboarded). // The 800 ms delay lets the tray menu settle before the window appears. @@ -1692,23 +1679,9 @@ fn tray_debug(window: tauri::Window, msg: String) { /// Once from `lib.rs`'s `setup()` on launch, and again from /// `commands::pause_for_duration` on resume. #[cfg(feature = "capture")] -/// Start (or restart) the in-process capture engine and its persisting consumers. -/// -/// # `db` is the HANDLE, deliberately not a pool -/// -/// The consumers below live for the whole tray process and write every ~2.5 s, so -/// they must resolve [`db_pool::DbPool::get`] **per write** rather than caching a -/// `SqlitePool`. This parameter used to be `Option` - a snapshot taken -/// once at start - and that was the 1.91.0-staging.2 write-wedge: `DbPool::close` -/// (run by `reload_daemon` around every daemon restart, precisely so the tray never -/// holds a connection across one) cannot reach a clone that escaped the handle, so -/// the capture connections spanned the daemon's shutdown WAL TRUNCATE checkpoint, -/// desynced their `-shm` view, and every subsequent write failed with `(code: 11) -/// database disk image is malformed` - permanently, on a database that was -/// provably healthy. See [`db_pool::from_app`] for the measured detail. pub(crate) fn start_capture( app_state: std::sync::Arc>, - db: db_pool::DbPool, + pool: Option, ) { use capture::{screenpipe::ScreenpipeEngine, CaptureEngine}; @@ -1782,10 +1755,7 @@ pub(crate) fn start_capture( // Handles both item shapes the engine can send — the primary per-tick frame // and secondary-monitor context samples (multi-screen capture) — through the // same ignore-list gate, routed to their own tables. - // The HANDLE, cloned (cheap - `Arc`-backed). Every write below resolves - // `.get()` afresh, so a daemon-restart close/reopen is followed rather than - // outlived. Never hoist this into a `SqlitePool` outside the loop. - let consumer_pool = db.clone(); + let consumer_pool = pool.clone(); let frame_ignore = capture_ignore.clone(); tauri::async_runtime::spawn(async move { while let Some(item) = rx.recv().await { @@ -1811,7 +1781,7 @@ pub(crate) fn start_capture( ); continue; } - let Some(p) = consumer_pool.get() else { + let Some(p) = consumer_pool.as_ref() else { continue; }; let row = meridian_core::CaptureFrameInsert { @@ -1822,20 +1792,8 @@ pub(crate) fn start_capture( text: frame.text, text_source: frame.text_source.as_str().to_string(), }; - if let Err(e) = meridian_core::insert_capture_frame(&p, &row).await { - // `cmd_err!`, not `%e`: this site logged only the outermost - // context (`insert capture_frame`) and threw the cause away, - // so a fleet-wide write wedge was undiagnosable from logs - - // the same swallowed-cause bug `cmd_err!` was written for. - let _ = crate::cmd_err!(e, "capture: failed to persist frame"); - // These writes land every ~2.5 s, which makes them both the - // first thing on the machine to notice a broken pool view and - // the natural place to heal it - so the app recovers within - // seconds instead of waiting for a relaunch the user has no - // reason to know about. No retry: the next frame is 2.5 s away - // and will use the fresh pool. - db_pool::raise_if_corrupt(&p, &e).await; - consumer_pool.recover_if_corrupt(&e).await; + if let Err(e) = meridian_core::insert_capture_frame(p, &row).await { + tracing::warn!(error = %e, "capture: failed to persist frame"); } } capture::CaptureItem::Secondary(sample) => { @@ -1857,7 +1815,7 @@ pub(crate) fn start_capture( ); continue; } - let Some(p) = consumer_pool.get() else { + let Some(p) = consumer_pool.as_ref() else { continue; }; let row = meridian_core::CaptureSecondaryScreenInsert { @@ -1867,13 +1825,8 @@ pub(crate) fn start_capture( window_name: sample.window_name, text: sample.text, }; - if let Err(e) = meridian_core::insert_capture_secondary_screen(&p, &row).await { - let _ = crate::cmd_err!( - e, - "capture: failed to persist secondary-screen sample" - ); - db_pool::raise_if_corrupt(&p, &e).await; - consumer_pool.recover_if_corrupt(&e).await; + if let Err(e) = meridian_core::insert_capture_secondary_screen(p, &row).await { + tracing::warn!(error = %e, "capture: failed to persist secondary-screen sample"); } } } @@ -1904,8 +1857,7 @@ pub(crate) fn start_capture( // UI event consumer: exits on cancel signal, which drops ui_rx, causing // the OS recorder thread to see tx.is_closed() within 500ms and exit. let (ui_tx, mut ui_rx) = tokio::sync::mpsc::channel::(256); - // The handle, not a pool - same reason as `consumer_pool` above. - let ui_pool = db; + let ui_pool = pool; let ui_ignore = capture_ignore; tauri::async_runtime::spawn(async move { loop { @@ -1919,11 +1871,9 @@ pub(crate) fn start_capture( if ui_ignore.lock().unwrap().should_drop_app(ev.app_name.as_deref()) { continue; } - let Some(p) = ui_pool.get() else { continue }; - if let Err(e) = meridian_core::insert_capture_ui_event(&p, &ev).await { - let _ = crate::cmd_err!(e, "capture: failed to persist ui event"); - db_pool::raise_if_corrupt(&p, &e).await; - ui_pool.recover_if_corrupt(&e).await; + let Some(p) = ui_pool.as_ref() else { continue }; + if let Err(e) = meridian_core::insert_capture_ui_event(p, &ev).await { + tracing::warn!(error = %e, "capture: failed to persist ui event"); } } } @@ -1958,50 +1908,3 @@ pub(crate) fn start_capture( s.ui_recorder_thread = Some(ui_recorder_thread); tracing::info!("capture: engine and ui recorder started"); } - -#[cfg(test)] -mod capture_pool_lifetime_tests { - /// The capture consumers must resolve the pool PER WRITE, never cache one. - /// - /// `start_capture` spawns three consumers that live for the whole tray - /// process and write every ~2.5 s. Until 1.91.0-staging.2 they captured - /// `Option` once at start, which `DbPool::close` (run by - /// `reload_daemon` around every daemon restart) cannot reach — so those - /// connections spanned the daemon's shutdown WAL TRUNCATE checkpoint, - /// desynced their `-shm` view, and every write from then on failed with - /// `(code: 11) database disk image is malformed` on a database that `db - /// check` reported healthy. Reads kept working, which is why it read as - /// corruption rather than as a connection bug. - /// - /// `a_pool_snapshot_dies_across_a_reload_but_the_handle_survives` in - /// `db_pool` proves the snapshot is dead after a reload; this proves these - /// particular call sites don't take one. It scans the source because the - /// consumers need a live tray app, an OS capture engine and a real daemon - /// restart to exercise — the same reason `main.rs`'s startup/shutdown - /// ordering tests scan rather than execute. - #[test] - fn capture_consumers_resolve_the_pool_per_write() { - const SRC: &str = include_str!("lib.rs"); - let prod = SRC - .split_once("\n#[cfg(test)]\nmod capture_pool_lifetime_tests") - .map_or(SRC, |(before, _)| before); - - for cached in ["let consumer_pool = pool.clone();", "let ui_pool = pool;"] { - assert!( - !prod.contains(cached), - "`{cached}` caches a SqlitePool for the capture consumers' whole \ - lifetime. Pass the `DbPool` handle and call `.get()` inside the \ - write loop instead - see `start_capture`'s doc for the wedge this \ - caused." - ); - } - - let handle_sites = - prod.matches("consumer_pool.get()").count() + prod.matches("ui_pool.get()").count(); - assert_eq!( - handle_sites, 3, - "expected all 3 capture write sites (frame, secondary-screen, ui event) \ - to resolve the handle per write, found {handle_sites}" - ); - } -} diff --git a/tray/src-tauri/src/poll/mod.rs b/tray/src-tauri/src/poll/mod.rs index 4e59633b2..832a1fc09 100644 --- a/tray/src-tauri/src/poll/mod.rs +++ b/tray/src-tauri/src/poll/mod.rs @@ -122,7 +122,7 @@ pub async fn run_poll_loop(app: tauri::AppHandle, state: Arc>) { // check_disk_space's doc comment for why writing into a nearly-full // disk must stop rather than degrade silently. if let Some(pool) = &pool { - check_disk_space(&app, &state, pool).await; + check_disk_space(&state, pool).await; } // Work-hours schedule enforcement: auto-pause capture outside the // configured window, auto-resume when entering it. Only fires when the @@ -223,11 +223,7 @@ fn update_tray_icon(app: &tauri::AppHandle, state: &Arc>) { /// timer) is separately gated in [`crate::commands::pause::resume_capture`], /// so a still-low disk can't be resumed into from any direction — this /// function only owns the disk-low pause's own start/end transition. -async fn check_disk_space( - app: &tauri::AppHandle, - state: &Arc>, - pool: &meridian_core::SqlitePool, -) { +async fn check_disk_space(state: &Arc>, pool: &meridian_core::SqlitePool) { let low = meridian::health::platform::meridian_data_low_gb().is_some(); let (pause_source, started_at, capture_paused_flag) = { @@ -351,11 +347,8 @@ async fn check_disk_space( // rare blip (engine starts and stops within the same tick, // capturing nothing) rather than a bug worth cross-checking // schedule state here too. - // The managed handle, not `pool` - see `start_capture`'s doc. #[cfg(feature = "capture")] - if let Some(db) = crate::db_pool::from_app(app) { - crate::start_capture(state.clone(), db); - } + crate::start_capture(state.clone(), Some(pool.clone())); tracing::info!(duration_s, "disk-space guard: capture resumed"); } _ => { @@ -451,12 +444,9 @@ async fn check_work_hours( s.schedule_resume_at = None; s.pause_until = None; } - // Restart engine so screen recording resumes. The managed handle, not - // `pool` - see `start_capture`'s doc. + // Restart engine so screen recording resumes. #[cfg(feature = "capture")] - if let Some(db) = crate::db_pool::from_app(app) { - crate::start_capture(state.clone(), db); - } + crate::start_capture(state.clone(), Some(pool.clone())); tracing::info!( duration_s, "work-hours: schedule pause ended — capture resumed" diff --git a/tray/src-tauri/src/poll/refresh.rs b/tray/src-tauri/src/poll/refresh.rs index 39b400499..eaaebcead 100644 --- a/tray/src-tauri/src/poll/refresh.rs +++ b/tray/src-tauri/src/poll/refresh.rs @@ -394,6 +394,47 @@ fn decide_health_notice( } } +/// If `err` indicates `meridian.db` is corrupt, raise the SAME `db.corrupt` +/// notice `main.rs`'s `etl_tick` raises on the daemon side - immediately, +/// from whichever side of the app noticed first. +/// +/// The daemon already had this covered for its own queries, but the tray +/// holds its own independent, long-lived pool on the same file (opened once +/// at startup, `lib.rs`'s `app.manage(db_pool)`) and reads different tables +/// on this loop's faster (~30 s) cadence than the daemon's ETL/summariser +/// ticks. In the incident this fixes, the tray's own reads here hit +/// `(code: 11) database disk image is malformed` a full 5+ minutes before any +/// daemon-side query happened to touch the same damage - and until this +/// function existed, that whole window was silent `tracing::warn!` noise with +/// no banner, because nothing on this side of the process ever called +/// `raise_typed`. Idempotent (`raise_typed` upserts), so calling this on +/// every failing tick is safe and cheap - it does not need its own latch the +/// way the daemon's ETL loop does, because a poll tick that keeps failing +/// just keeps refreshing the same notice row rather than retrying a query +/// with side effects. +async fn raise_if_corrupt(pool: &SqlitePool, err: &anyhow::Error) { + if !meridian::db::integrity::is_corrupt_error(err) { + return; + } + let _ = meridian::notices::raise_typed( + pool, + meridian::notices::Notice { + id: meridian::notices::DB_CORRUPT, + severity: "error", + title: "Meridian's database is damaged", + // Full chain, not `err.to_string()` - same reasoning as + // `crate::cmd_err!`'s doc comment: `anyhow::Error`'s `Display` + // renders only the outermost `.context()` and would otherwise + // drop the SQLite code a reader needs. + detail: &format!("{err:#}"), + remedy: Some("Quit Meridian, then run 'meridian db repair' in a terminal"), + event_key: meridian::notices::DB_CORRUPT, + deep_link: Some(meridian_core::notifications::deep_links::LOGS), + }, + ) + .await; +} + /// Read the active session (direct DB) and store the app name + elapsed seconds. /// On a read error we keep the previous value rather than clearing the pill on a /// transient blip. @@ -414,7 +455,7 @@ pub(super) async fn refresh_active(pool: &SqlitePool, state: &Arc { tracing::warn!(error = %meridian::errors::chain(&e), "refresh_current_task failed"); - crate::db_pool::raise_if_corrupt(pool, &e).await; + raise_if_corrupt(pool, &e).await; } } } @@ -509,7 +550,7 @@ pub(super) async fn refresh_today(pool: &SqlitePool, state: &Arc } Err(e) => { tracing::warn!(error = %meridian::errors::chain(&e), "refresh_today failed"); - crate::db_pool::raise_if_corrupt(pool, &e).await; + raise_if_corrupt(pool, &e).await; } } } @@ -544,7 +585,7 @@ pub(super) async fn refresh_worklogs(pool: &SqlitePool, state: &Arc { tracing::warn!(error = %meridian::errors::chain(&e), "refresh_worklogs failed"); - crate::db_pool::raise_if_corrupt(pool, &e).await; + raise_if_corrupt(pool, &e).await; } } } @@ -956,8 +997,54 @@ mod tests { assert!(!recovered.reconcile_stale); } - // `raise_if_corrupt`'s own tests moved with it to `crate::db_pool`, which - // is where the four call sites above now point. + /// The tray's own reads must raise `db.corrupt` the moment THEY hit + /// corruption, not wait for a daemon-side query to stumble onto the same + /// damage minutes later — the gap this fix closes. Real corrupted bytes on + /// disk aren't needed: `raise_if_corrupt` only inspects the error, and + /// `db::integrity::is_corrupt_error` (the classifier it delegates to) is + /// already pinned against the real field-incident shape elsewhere. + #[tokio::test] + async fn raise_if_corrupt_writes_the_notice_on_a_corrupt_error() { + let pool = fresh_db().await; + let err = anyhow::anyhow!( + "error returned from database: (code: 11) database disk image is malformed" + ) + .context("current_task: fetch most recent task session"); + + raise_if_corrupt(&pool, &err).await; + + let row: (String, String) = + sqlx::query_as("SELECT severity, detail FROM system_notices WHERE notice_id = ?") + .bind(meridian::notices::DB_CORRUPT) + .fetch_one(&pool) + .await + .expect("db.corrupt notice must be written"); + assert_eq!(row.0, "error"); + assert!( + row.1.contains("database disk image is malformed"), + "notice detail dropped the actual cause: {}", + row.1 + ); + } + + /// Every other read failure (a lock, a missing table, a network blip on an + /// unrelated call) must NOT raise the corruption banner — that would train + /// the user to run `db repair` for faults it can't fix. + #[tokio::test] + async fn raise_if_corrupt_is_silent_on_unrelated_errors() { + let pool = fresh_db().await; + let err = anyhow::anyhow!("database is locked").context("today: fetch sessions"); + + raise_if_corrupt(&pool, &err).await; + + let count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM system_notices WHERE notice_id = ?") + .bind(meridian::notices::DB_CORRUPT) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0, "an unrelated error must not raise db.corrupt"); + } async fn fresh_db() -> meridian_core::SqlitePool { use sqlx::sqlite::SqliteConnectOptions; From fd0db1e97521d985f193365b45fc1a30556c7821 Mon Sep 17 00:00:00 2001 From: adityaharishch Date: Thu, 27 Aug 2026 00:06:35 +0530 Subject: [PATCH 19/53] Revert "Merge pull request #909 from Meridiona/fix/single-owner-pm-sync" This reverts commit a6ee29697cac5d63b0ca1c2aa0c710b386a4ff86, reversing changes made to 888de374c5d3678146c685fa927ac054003c8902. --- meridian-core/src/lib.rs | 4 - meridian-core/src/pm_sync_requests/mod.rs | 279 ------------ meridian-core/src/pm_sync_requests/tests.rs | 266 ----------- meridian-oauth/src/{jira/mod.rs => jira.rs} | 102 ++--- meridian-oauth/src/jira/tests.rs | 121 ----- src/health/jira.rs | 113 +---- src/intelligence/mod.rs | 113 +---- src/intelligence/oauth/jira.rs | 89 +--- src/intelligence/providers/jira/refresh.rs | 150 +++---- src/intelligence/sync_delegate.rs | 320 ------------- src/intelligence/sync_requests.rs | 227 ---------- src/main.rs | 200 ++------- src/migrations/082_pm_sync_requests.sql | 68 --- src/plan_tasks/create.rs | 36 +- src/plan_tasks/done.rs | 17 +- src/plan_tasks/edit.rs | 17 +- .../sweep.rs => auto_generate.rs} | 237 ++++++++-- src/pm_worklog/auto_generate/mod.rs | 216 --------- tray/src-tauri/src/commands/integrations.rs | 28 +- tray/src-tauri/src/commands/tasks.rs | 421 ++++-------------- tray/src-tauri/src/lib.rs | 1 - ui/components/plan/PlanView.tsx | 31 +- .../timeline/WorklogTicketPicker.tsx | 16 +- .../timeline/settings/IntegrationsSection.tsx | 2 +- ui/lib/taskSync.ts | 56 --- 25 files changed, 477 insertions(+), 2653 deletions(-) delete mode 100644 meridian-core/src/pm_sync_requests/mod.rs delete mode 100644 meridian-core/src/pm_sync_requests/tests.rs rename meridian-oauth/src/{jira/mod.rs => jira.rs} (85%) delete mode 100644 meridian-oauth/src/jira/tests.rs delete mode 100644 src/intelligence/sync_delegate.rs delete mode 100644 src/intelligence/sync_requests.rs delete mode 100644 src/migrations/082_pm_sync_requests.sql rename src/pm_worklog/{auto_generate/sweep.rs => auto_generate.rs} (53%) delete mode 100644 src/pm_worklog/auto_generate/mod.rs diff --git a/meridian-core/src/lib.rs b/meridian-core/src/lib.rs index fa07d5253..9653d4712 100644 --- a/meridian-core/src/lib.rs +++ b/meridian-core/src/lib.rs @@ -44,10 +44,6 @@ pub mod settings; /// Notification delivery policy + native pending queue (ported from lib/notifications.ts). pub mod notifications; -/// Single-owner PM sync request outbox (migration 082) - producers ask, the daemon -/// alone services them, so the rotating Jira OAuth token has exactly one writer. -pub mod pm_sync_requests; - /// The `~/.meridian/plan_auto_opened` marker format — written by the tray's /// daily planner auto-open, read by the daemon's plan-nudge hold-back. pub mod plan_marker; diff --git a/meridian-core/src/pm_sync_requests/mod.rs b/meridian-core/src/pm_sync_requests/mod.rs deleted file mode 100644 index ed070c6b5..000000000 --- a/meridian-core/src/pm_sync_requests/mod.rs +++ /dev/null @@ -1,279 +0,0 @@ -//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity -//! Single-owner PM sync: the request side of the outbox (`pm_sync_requests`, -//! migration 082). -//! -//! # Why sync is a request instead of an action -//! -//! An Atlassian OAuth refresh token is single-use and rotating. The old token dies -//! the instant the new one is issued, so a lost response leaves the grant -//! recoverable only inside a 10-minute window and permanently dead after it. That -//! makes the token a resource with exactly ONE safe writer. -//! -//! It had several: the tray refreshed in-process, the daemon refreshed on its poll -//! loop, and the tray spawned fresh `meridian pm-sync` / `tasks-sync` processes that -//! each refreshed too. The advisory file lock meant to serialise them could not -//! actually do it - its 10 s timeout is shorter than the ~26 s a refresh can take -//! (3 attempts x 8 s plus backoff), and on timeout the code proceeded WITHOUT the -//! lock rather than backing off. So two processes could spend the same token, and -//! the only thing preventing corruption was Atlassian's grace window handing the -//! loser the current pair. -//! -//! Producers now write a row here and the **daemon is the sole consumer**, so the -//! credential is held by one process by construction rather than by lock discipline. -//! -//! # Who calls this -//! - Producers: `tray/src-tauri/src/commands/tasks.rs` (window opens, tracker -//! connect, "Sync now"), and the `meridian tasks-sync` / `pm-sync` CLIs when a -//! daemon is running. -//! - Consumer: the daemon's sync-request watcher (`src/intelligence/sync_requests.rs`). -//! -//! # Related -//! - [`crate::notifications`] - the outbox pattern this mirrors. - -use anyhow::{Context, Result}; -use sqlx::SqlitePool; - -/// The all-providers request every current producer writes. A specific provider -/// name scopes a request to one board, reserved for a future caller that needs it. -pub const ALL_PROVIDERS: &str = "*"; - -/// Whether the daemon should honour the per-provider staleness window or bypass it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SyncMode { - /// Honour the staleness window - the cheap common case (a window opening). - Gated, - /// Bypass it: the user explicitly asked (connected a tracker, pressed "Sync - /// now", ran a CLI). - Force, -} - -impl SyncMode { - /// The stored discriminant. Kept as text so the row is readable in `sqlite3` - /// during support work. - pub fn as_str(self) -> &'static str { - match self { - SyncMode::Gated => "gated", - SyncMode::Force => "force", - } - } - - /// Parse a stored discriminant, defaulting to the SAFER option. An unknown or - /// corrupt value must never silently become a `Force` that bypasses the - /// staleness gate and hammers the provider's API. - pub fn from_str_or_gated(s: &str) -> Self { - match s { - "force" => SyncMode::Force, - _ => SyncMode::Gated, - } - } -} - -/// One pending request, as claimed by the daemon. -#[derive(Debug, Clone)] -pub struct SyncRequest { - pub provider: String, - pub mode: SyncMode, - pub reason: String, -} - -/// Ask the daemon to sync PM tasks. Idempotent and coalescing: repeated calls -/// collapse into the single pending row rather than queueing, so opening the -/// dashboard ten times means "a sync is wanted", not ten syncs. -/// -/// `mode` **escalates only**. A `Force` landing on a pending `Gated` upgrades it, -/// because a user who just connected a tracker must not have that downgraded by a -/// passing window focus; a `Gated` landing on a pending `Force` leaves the `Force` -/// intact. Writing a new request also clears any previous completion stamps, so the -/// row unambiguously represents work still to do. -/// -/// `reason` is a producer tag for tracing only (`"dashboard_open"`, -/// `"token_connected"`). Never pass user content - it is read back into logs. -#[tracing::instrument(skip(pool))] -pub async fn request( - pool: &SqlitePool, - provider: &str, - mode: SyncMode, - reason: &str, -) -> Result<()> { - sqlx::query( - "INSERT INTO pm_sync_requests - (provider, mode, reason, requested_at, claimed_at, completed_at, error, synced_count) - VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), NULL, NULL, NULL, NULL) - ON CONFLICT(provider) DO UPDATE SET - -- Escalate to 'force', never back down from it while still pending. - -- - -- The `completed_at IS NULL` half is load-bearing: the row is kept after a - -- sync finishes (so \"Sync now\" can read its result), so without it a - -- SPENT 'force' would be inherited forever and every later gated request - -- would silently escalate. One tracker connect would then make every - -- planner open bypass the staleness gate and hit the provider for real - - -- reinstating the constant polling this whole design removes, and - -- multiplying exactly the token refreshes it exists to reduce. - mode = CASE - WHEN excluded.mode = 'force' - OR (pm_sync_requests.mode = 'force' - AND pm_sync_requests.completed_at IS NULL) - THEN 'force' - ELSE excluded.mode - END, - reason = excluded.reason, - requested_at = excluded.requested_at, - -- A fresh request re-opens the row: drop the in-flight and completion - -- marks so the watcher sees pending work again. - claimed_at = NULL, - completed_at = NULL, - error = NULL, - synced_count = NULL", - ) - .bind(provider) - .bind(mode.as_str()) - .bind(reason) - .execute(pool) - .await - .context("writing a PM sync request")?; - tracing::debug!(provider, mode = mode.as_str(), reason, "PM sync requested"); - Ok(()) -} - -/// Claim the pending request for `provider`, if there is one, marking it in-flight -/// so a second watcher tick can't pick up the same work. -/// -/// The claim is a conditional UPDATE (`WHERE claimed_at IS NULL AND completed_at IS -/// NULL`) rather than a read-then-write, so two daemons racing on the same file - -/// which the single-instance guard makes unlikely but not impossible during a -/// restart overlap - cannot both claim it. SQLite serialises the statement, so -/// exactly one sees a non-zero `rows_affected`. -#[tracing::instrument(skip(pool))] -pub async fn claim(pool: &SqlitePool, provider: &str) -> Result> { - let claimed = sqlx::query( - "UPDATE pm_sync_requests - SET claimed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') - WHERE provider = ? - AND claimed_at IS NULL - AND completed_at IS NULL", - ) - .bind(provider) - .execute(pool) - .await - .context("claiming a PM sync request")?; - - if claimed.rows_affected() == 0 { - return Ok(None); - } - - let row: Option<(String, String)> = - sqlx::query_as("SELECT mode, reason FROM pm_sync_requests WHERE provider = ?") - .bind(provider) - .fetch_optional(pool) - .await - .context("reading the claimed PM sync request")?; - - Ok(row.map(|(mode, reason)| SyncRequest { - provider: provider.to_string(), - mode: SyncMode::from_str_or_gated(&mode), - reason, - })) -} - -/// Record the outcome of a serviced request in place. The row is deliberately kept -/// rather than deleted so `"Sync now"` can read a real result without holding the -/// credential, and so support has a content-free view of the last attempt. -/// -/// Writes only if the row is still the one that was claimed. The guard is -/// **`claimed_at IS NOT NULL`**, and that specific predicate is the whole point: -/// [`request`] resets `claimed_at` to `NULL`, so a request that arrived mid-sync -/// makes this UPDATE match nothing. Guarding on `completed_at IS NULL` alone would -/// NOT work - the fresh request leaves that NULL too, so the older sync's outcome -/// would stamp the new request as done without it ever being serviced, and the -/// user's "Sync now" would report success for a sync that never ran. -#[tracing::instrument(skip(pool))] -pub async fn complete( - pool: &SqlitePool, - provider: &str, - synced_count: Option, - error: Option<&str>, -) -> Result<()> { - sqlx::query( - "UPDATE pm_sync_requests - SET completed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), - error = ?, - synced_count = ? - WHERE provider = ? - AND claimed_at IS NOT NULL - AND completed_at IS NULL", - ) - .bind(error) - .bind(synced_count) - .bind(provider) - .execute(pool) - .await - .context("completing a PM sync request")?; - Ok(()) -} - -/// Release claims left in flight by a daemon that died mid-sync, so the request -/// becomes claimable again. Call once at watcher startup. -/// -/// Without this a crash, a `meridian restart`, or a SIGKILL between [`claim`] and -/// [`complete`] strands the row **claimed but never completed** - and since [`claim`] -/// requires `claimed_at IS NULL`, no future tick would ever pick it up. PM sync -/// would then be silently dead until the next new request happened to reset the row. -/// Same reasoning as the daemon's `cleanup_incomplete_runs` for partial ETL runs. -/// -/// Only ever widens what is claimable, so it is safe to run unconditionally on every -/// boot: a genuinely in-flight sync cannot exist yet, because the only consumer is -/// the watcher this runs before. -#[tracing::instrument(skip(pool))] -pub async fn reset_stale_claims(pool: &SqlitePool) -> Result { - let res = sqlx::query( - "UPDATE pm_sync_requests - SET claimed_at = NULL - WHERE claimed_at IS NOT NULL - AND completed_at IS NULL", - ) - .execute(pool) - .await - .context("resetting stale PM sync request claims")?; - let n = res.rows_affected(); - if n > 0 { - tracing::info!( - reset = n, - "released PM sync claims stranded by a previous daemon exit" - ); - } - Ok(n) -} - -/// The outcome of the last request for `provider`, for a producer that wants to -/// show one ("Sync now"). `None` while the request is still pending or in flight, -/// so a caller can poll this until it turns `Some`. -pub async fn outcome(pool: &SqlitePool, provider: &str) -> Result> { - let row: Option<(Option, Option, Option)> = sqlx::query_as( - "SELECT completed_at, error, synced_count FROM pm_sync_requests WHERE provider = ?", - ) - .bind(provider) - .fetch_optional(pool) - .await - .context("reading the PM sync outcome")?; - - Ok(match row { - Some((Some(_completed), error, synced_count)) => Some(SyncOutcome { - error, - synced_count, - }), - // No row, or a row still pending / in flight. - _ => None, - }) -} - -/// A completed request's result. -#[derive(Debug, Clone)] -pub struct SyncOutcome { - /// `None` on success, the failure detail otherwise. - pub error: Option, - /// Tasks refreshed, when the daemon reported a count. - pub synced_count: Option, -} - -#[cfg(test)] -mod tests; diff --git a/meridian-core/src/pm_sync_requests/tests.rs b/meridian-core/src/pm_sync_requests/tests.rs deleted file mode 100644 index 2bf861c66..000000000 --- a/meridian-core/src/pm_sync_requests/tests.rs +++ /dev/null @@ -1,266 +0,0 @@ -//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity -//! Tests for the PM sync request outbox. -//! -//! Split out of `mod.rs` purely for the 500-line file cap; they are the module's own -//! unit tests and belong to it. Each one pins a race or a policy that is invisible -//! from the SQL alone - read them alongside the doc comment on the function they -//! exercise. - -use super::*; -use sqlx::sqlite::SqliteConnectOptions; -use std::str::FromStr; - -async fn db() -> SqlitePool { - let opts = SqliteConnectOptions::from_str("sqlite::memory:") - .unwrap() - .create_if_missing(true); - let pool = SqlitePool::connect_with(opts).await.unwrap(); - sqlx::query( - "CREATE TABLE pm_sync_requests ( - provider TEXT NOT NULL PRIMARY KEY, - mode TEXT NOT NULL DEFAULT 'gated', - reason TEXT NOT NULL DEFAULT '', - requested_at TEXT NOT NULL, - claimed_at TEXT, - completed_at TEXT, - error TEXT, - synced_count INTEGER - )", - ) - .execute(&pool) - .await - .unwrap(); - pool -} - -/// Repeated requests must COALESCE into one pending row. Ten planner opens -/// mean "a sync is wanted", not ten syncs. -#[tokio::test] -async fn repeated_requests_coalesce_into_one_row() { - let pool = db().await; - for _ in 0..10 { - request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") - .await - .unwrap(); - } - let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pm_sync_requests") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(n, 1); -} - -/// A user action must not be downgraded by a passing window focus. -#[tokio::test] -async fn force_survives_a_later_gated_request() { - let pool = db().await; - request(&pool, ALL_PROVIDERS, SyncMode::Force, "token_connected") - .await - .unwrap(); - request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") - .await - .unwrap(); - - let req = claim(&pool, ALL_PROVIDERS).await.unwrap().expect("pending"); - assert_eq!(req.mode, SyncMode::Force); -} - -/// ...and a user action must be able to escalate a pending gated request. -#[tokio::test] -async fn gated_escalates_to_force() { - let pool = db().await; - request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") - .await - .unwrap(); - request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") - .await - .unwrap(); - - let req = claim(&pool, ALL_PROVIDERS).await.unwrap().expect("pending"); - assert_eq!(req.mode, SyncMode::Force); -} - -/// A *spent* force must NOT be inherited. The row survives completion so "Sync -/// now" can read its result, so escalation has to be scoped to a still-pending -/// row - otherwise one tracker connect leaves `mode = 'force'` set forever and -/// every later planner open bypasses the staleness gate and hits the provider -/// for real, multiplying the token refreshes this design exists to reduce. -#[tokio::test] -async fn a_completed_force_does_not_escalate_the_next_gated_request() { - let pool = db().await; - request(&pool, ALL_PROVIDERS, SyncMode::Force, "token_connected") - .await - .unwrap(); - claim(&pool, ALL_PROVIDERS).await.unwrap(); - complete(&pool, ALL_PROVIDERS, Some(4), None).await.unwrap(); - - // A later window open wants the cheap, gated behaviour. - request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") - .await - .unwrap(); - - let req = claim(&pool, ALL_PROVIDERS).await.unwrap().expect("pending"); - assert_eq!( - req.mode, - SyncMode::Gated, - "a spent force must not escalate later gated requests" - ); -} - -/// The in-flight case still escalates: a force that is claimed but not completed -/// will have its outcome discarded by `complete`'s guard and be re-serviced, so -/// the force intent must survive into that re-run. -#[tokio::test] -async fn an_in_flight_force_still_survives_a_gated_request() { - let pool = db().await; - request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") - .await - .unwrap(); - claim(&pool, ALL_PROVIDERS).await.unwrap(); - - request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") - .await - .unwrap(); - - let req = claim(&pool, ALL_PROVIDERS).await.unwrap().expect("pending"); - assert_eq!(req.mode, SyncMode::Force); -} - -/// A claim is exclusive: the second attempt sees nothing, so two watcher ticks -/// can never run the same sync twice. -#[tokio::test] -async fn claim_is_exclusive() { - let pool = db().await; - request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") - .await - .unwrap(); - assert!(claim(&pool, ALL_PROVIDERS).await.unwrap().is_some()); - assert!( - claim(&pool, ALL_PROVIDERS).await.unwrap().is_none(), - "a claimed request must not be claimable again" - ); -} - -/// Nothing pending is a quiet `None`, not an error - the watcher ticks on this -/// constantly. -#[tokio::test] -async fn claim_with_no_request_is_none() { - let pool = db().await; - assert!(claim(&pool, ALL_PROVIDERS).await.unwrap().is_none()); -} - -/// A daemon killed between claim and complete must not strand PM sync forever. -/// Without the reset the row stays claimed, `claim` requires `claimed_at IS -/// NULL`, and no future tick could ever service it. -#[tokio::test] -async fn stale_claims_are_released_on_startup() { - let pool = db().await; - request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") - .await - .unwrap(); - claim(&pool, ALL_PROVIDERS).await.unwrap(); - // ... daemon dies here, no `complete` ever runs. - - assert!( - claim(&pool, ALL_PROVIDERS).await.unwrap().is_none(), - "precondition: a stranded claim blocks re-claiming" - ); - - assert_eq!(reset_stale_claims(&pool).await.unwrap(), 1); - - let req = claim(&pool, ALL_PROVIDERS) - .await - .unwrap() - .expect("the request must be serviceable again after the reset"); - assert_eq!(req.mode, SyncMode::Force); -} - -/// The reset must not disturb a request that already completed - that would -/// re-run finished work on every daemon boot. -#[tokio::test] -async fn reset_leaves_completed_requests_alone() { - let pool = db().await; - request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") - .await - .unwrap(); - claim(&pool, ALL_PROVIDERS).await.unwrap(); - complete(&pool, ALL_PROVIDERS, Some(2), None).await.unwrap(); - - assert_eq!(reset_stale_claims(&pool).await.unwrap(), 0); - assert!( - claim(&pool, ALL_PROVIDERS).await.unwrap().is_none(), - "a completed request must stay completed" - ); -} - -/// The outcome is invisible until the daemon finishes, so a producer polling it -/// can tell "still working" from "done". -#[tokio::test] -async fn outcome_is_none_until_completed() { - let pool = db().await; - request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") - .await - .unwrap(); - assert!(outcome(&pool, ALL_PROVIDERS).await.unwrap().is_none()); - - claim(&pool, ALL_PROVIDERS).await.unwrap(); - assert!( - outcome(&pool, ALL_PROVIDERS).await.unwrap().is_none(), - "in-flight must still read as pending" - ); - - complete(&pool, ALL_PROVIDERS, Some(7), None).await.unwrap(); - let out = outcome(&pool, ALL_PROVIDERS).await.unwrap().expect("done"); - assert_eq!(out.synced_count, Some(7)); - assert!(out.error.is_none()); -} - -/// A failure is reported, not swallowed. -#[tokio::test] -async fn outcome_carries_the_error() { - let pool = db().await; - request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") - .await - .unwrap(); - claim(&pool, ALL_PROVIDERS).await.unwrap(); - complete(&pool, ALL_PROVIDERS, None, Some("401 unauthorized")) - .await - .unwrap(); - - let out = outcome(&pool, ALL_PROVIDERS).await.unwrap().expect("done"); - assert_eq!(out.error.as_deref(), Some("401 unauthorized")); -} - -/// THE RACE THIS GUARDS: a request arriving mid-sync resets the row, and the -/// older sync's outcome must NOT stamp it complete - that would mark the new -/// request done without ever servicing it. -#[tokio::test] -async fn completion_does_not_clobber_a_request_that_arrived_mid_sync() { - let pool = db().await; - request(&pool, ALL_PROVIDERS, SyncMode::Gated, "dashboard_open") - .await - .unwrap(); - claim(&pool, ALL_PROVIDERS).await.unwrap(); - - // A new request lands while the first sync is still running. - request(&pool, ALL_PROVIDERS, SyncMode::Force, "sync_now") - .await - .unwrap(); - - // The in-flight sync finishes and tries to report. This must be a NO-OP: - // the new request cleared `claimed_at`, so the guard rejects it. - complete(&pool, ALL_PROVIDERS, Some(3), None).await.unwrap(); - - assert!( - outcome(&pool, ALL_PROVIDERS).await.unwrap().is_none(), - "the older sync's outcome must NOT mark the new request complete - \ - that would report success for a sync that never ran" - ); - - let req = claim(&pool, ALL_PROVIDERS) - .await - .unwrap() - .expect("the mid-sync request must still be pending"); - assert_eq!(req.mode, SyncMode::Force); - assert_eq!(req.reason, "sync_now"); -} diff --git a/meridian-oauth/src/jira/mod.rs b/meridian-oauth/src/jira.rs similarity index 85% rename from meridian-oauth/src/jira/mod.rs rename to meridian-oauth/src/jira.rs index 37ff9086c..198901125 100644 --- a/meridian-oauth/src/jira/mod.rs +++ b/meridian-oauth/src/jira.rs @@ -236,59 +236,9 @@ pub async fn login(client_id: &str, client_secret: &str, port: u16) -> Result Option { - let t = store::load("jira").ok()?; - if t.is_expired(now_unix(), EXPIRY_SKEW_SECS) { - tracing::debug!( - "jira access token expired - unattended caller deferring rather than refreshing" - ); - return None; - } - Some(t) -} - /// Load the stored tokens, refreshing the access token if it's within 120 s of /// expiry. Persists the rotated refresh token. Returns ready-to-use tokens. /// -/// **Only call this on the path of a real user action.** Spending the rotating -/// refresh token from a background timer is what permanently kills a grant when -/// the machine suspends mid-request; see [`current_if_valid`] for the full -/// reasoning and the unattended alternative. -/// /// Refreshes are serialised on TWO levels so the rotating refresh token is never /// double-spent: a static mutex within this process, and an advisory FILE lock /// ([`store::lock_provider`]) across every Meridian process. After taking the file @@ -300,7 +250,7 @@ pub async fn ensure_fresh() -> Result { // Fast path: a still-fresh token needs neither a refresh nor the file lock. let t = store::load("jira")?; - if !t.is_expired(now_unix(), EXPIRY_SKEW_SECS) { + if !t.is_expired(now_unix(), 120) { return Ok(t); } @@ -323,7 +273,7 @@ pub async fn ensure_fresh() -> Result { // adopt their token instead of refreshing again with the dead one. This // double-check is what actually breaks the race. let mut t = store::load("jira")?; - if !t.is_expired(now_unix(), EXPIRY_SKEW_SECS) { + if !t.is_expired(now_unix(), 120) { tracing::debug!("jira token already refreshed by another process — adopting it"); return Ok(t); } @@ -420,4 +370,50 @@ impl JiraReqCtx { } #[cfg(test)] -mod tests; +mod tests { + use super::*; + + fn oauth_ctx() -> JiraReqCtx { + JiraReqCtx::OAuth { + token: "tok".into(), + cloud_id: "cloud-xyz".into(), + site_url: "https://acme.atlassian.net".into(), + } + } + + fn basic_ctx() -> JiraReqCtx { + JiraReqCtx::Basic { + base_url: "https://acme.atlassian.net/".into(), + email: "a@b.com".into(), + api_token: "tok".into(), + } + } + + #[test] + fn oauth_api_url_uses_gateway() { + assert_eq!( + oauth_ctx().api_url("/rest/api/3/search/jql"), + "https://api.atlassian.com/ex/jira/cloud-xyz/rest/api/3/search/jql" + ); + } + + #[test] + fn basic_api_url_uses_site_and_trims_slash() { + assert_eq!( + basic_ctx().api_url("/rest/api/3/search/jql"), + "https://acme.atlassian.net/rest/api/3/search/jql" + ); + } + + #[test] + fn browse_url_uses_site_in_both_modes() { + assert_eq!( + oauth_ctx().browse_url("KAN-1"), + "https://acme.atlassian.net/browse/KAN-1" + ); + assert_eq!( + basic_ctx().browse_url("KAN-1"), + "https://acme.atlassian.net/browse/KAN-1" + ); + } +} diff --git a/meridian-oauth/src/jira/tests.rs b/meridian-oauth/src/jira/tests.rs deleted file mode 100644 index 7293dcd30..000000000 --- a/meridian-oauth/src/jira/tests.rs +++ /dev/null @@ -1,121 +0,0 @@ -//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity -//! Tests for the Jira OAuth flow and token handling. -//! -//! Split out of `mod.rs` for the 500-line file cap; they are this module's own unit -//! tests. The `current_if_valid` cases pin the attended/unattended split - the rule -//! that unattended code may USE a valid access token but must never MINT one, which -//! is what stops a background refresh being destroyed by a laptop suspend. - -use super::*; - -fn oauth_ctx() -> JiraReqCtx { - JiraReqCtx::OAuth { - token: "tok".into(), - cloud_id: "cloud-xyz".into(), - site_url: "https://acme.atlassian.net".into(), - } -} - -fn basic_ctx() -> JiraReqCtx { - JiraReqCtx::Basic { - base_url: "https://acme.atlassian.net/".into(), - email: "a@b.com".into(), - api_token: "tok".into(), - } -} - -#[test] -fn oauth_api_url_uses_gateway() { - assert_eq!( - oauth_ctx().api_url("/rest/api/3/search/jql"), - "https://api.atlassian.com/ex/jira/cloud-xyz/rest/api/3/search/jql" - ); -} - -#[test] -fn basic_api_url_uses_site_and_trims_slash() { - assert_eq!( - basic_ctx().api_url("/rest/api/3/search/jql"), - "https://acme.atlassian.net/rest/api/3/search/jql" - ); -} - -#[test] -fn browse_url_uses_site_in_both_modes() { - assert_eq!( - oauth_ctx().browse_url("KAN-1"), - "https://acme.atlassian.net/browse/KAN-1" - ); - assert_eq!( - basic_ctx().browse_url("KAN-1"), - "https://acme.atlassian.net/browse/KAN-1" - ); -} - -/// Seed a jira token store under a private `$HOME` with `expires_at` at -/// `now + offset_secs`. Returns the guard that keeps `$HOME` stable for the -/// duration of the test. -fn seed_store(tag: &str, offset_secs: i64) -> std::sync::MutexGuard<'static, ()> { - let guard = crate::env_test_guard(); - let dir = std::env::temp_dir().join(format!("meridian_oauth_civ_{tag}_{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::env::set_var("HOME", &dir); - store::save(&OAuthTokens { - provider: "jira".into(), - client_id: "cid".into(), - access_token: "access".into(), - refresh_token: "refresh".into(), - expires_at: now_unix() + offset_secs, - scopes: String::new(), - cloud_id: "cloud-xyz".into(), - site_url: "https://acme.atlassian.net".into(), - }) - .expect("seeding the token store should succeed"); - guard -} - -/// A comfortably-valid access token is handed straight back, so an unattended -/// sweep can still refresh the ticket cache without touching the network. -#[test] -fn current_if_valid_returns_a_live_token() { - let _g = seed_store("live", 3600); - let t = current_if_valid().expect("a token an hour from expiry must be usable"); - assert_eq!(t.access_token, "access"); - assert_eq!(t.cloud_id, "cloud-xyz"); -} - -/// THE POINT OF THE FUNCTION: an expired token yields `None` rather than -/// triggering a refresh. Spending the rotating refresh token unattended is what -/// permanently kills a grant when the machine suspends mid-POST, so a -/// clock-driven caller must defer instead. -#[test] -fn current_if_valid_refuses_to_mint_when_expired() { - let _g = seed_store("expired", -10); - assert!( - current_if_valid().is_none(), - "an expired token must NOT be returned - the caller would use it and 401, \ - and the whole point is to defer to the next attended request" - ); -} - -/// The skew is applied, not just raw expiry: a token inside the margin counts as -/// expired so a request can't be issued with one that dies mid-flight. -#[test] -fn current_if_valid_applies_the_expiry_skew() { - let _g = seed_store("skew", EXPIRY_SKEW_SECS - 10); - assert!( - current_if_valid().is_none(), - "a token inside the {EXPIRY_SKEW_SECS}s skew must be treated as expired" - ); -} - -/// No store at all is a `None`, never a panic or an error — an install that has -/// never connected Jira must sweep quietly. -#[test] -fn current_if_valid_handles_a_missing_store() { - let _g = crate::env_test_guard(); - let dir = std::env::temp_dir().join(format!("meridian_oauth_civ_none_{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::env::set_var("HOME", &dir); - assert!(current_if_valid().is_none()); -} diff --git a/src/health/jira.rs b/src/health/jira.rs index 23624db4e..ac4ec38a6 100644 --- a/src/health/jira.rs +++ b/src/health/jira.rs @@ -5,16 +5,6 @@ // collapses both into a silent warn. Sync freshness + candidate count come from // meridian.db (content-free). Creds are read from the env (loaded via dotenv at // startup), so this works without reaching into Config internals. -// -// `sync_freshness` is outcome-driven (`pm_sync_state.last_error`), not -// elapsed-time-driven. Syncing is on-demand now (the daily plan, the match-to-ticket -// picker, connecting a tracker, a board write, manual "Sync now") rather than a -// background poller ticking every ~5 minutes forever — so a quiet period with no sync -// attempt is normal, not a symptom. A laptop shut overnight is the everyday case: it -// wakes with a cache many hours old and nothing wrong. `last_error` reflects the actual outcome of the last attempted fetch -// (set by `intelligence::providers::record_sync_failure`, cleared by -// `clear_sync_error` on the next success), which stays correct however long that -// was ago. use crate::config::Config; use crate::health::Check; @@ -22,6 +12,9 @@ use crate::intelligence::oauth::{jira as oauth_jira, store as oauth_store}; use sqlx::SqlitePool; use std::time::Duration; +/// Cache older than this (2× the 30-min sync interval) ⇒ fetch likely failing. +const SYNC_STALE_SECS: f64 = 3600.0; + pub async fn checks(_cfg: &Config, pool: Option<&SqlitePool>) -> Vec { let mut out = Vec::new(); @@ -140,29 +133,28 @@ async fn classify_auth(send: reqwest::Result) -> Check { } async fn sync_freshness(pool: &SqlitePool) -> Check { - match sqlx::query_as::<_, (Option, Option)>( - "SELECT last_error, (julianday('now') - julianday(last_synced_at)) * 86400.0 + match sqlx::query_scalar::<_, Option>( + "SELECT (julianday('now') - julianday(MAX(last_synced_at))) * 86400.0 FROM pm_sync_state WHERE provider = 'jira'", ) - .fetch_optional(pool) + .fetch_one(pool) .await { - // A recorded failure is the real signal — the last attempted fetch didn't - // work, regardless of how long ago that was. Never a warn from elapsed time - // alone: a long quiet period between on-demand syncs is expected now. - Ok(Some((Some(err), _))) => { - Check::warn("ticket sync", "L3", format!("sync failing: {err}")).with_remedy( - "check the auth row above, or Reconnect Jira in Settings - Integrations", - ) - } - Ok(Some((None, Some(age)))) => Check::ok( + Ok(Some(age)) if age > SYNC_STALE_SECS => Check::warn( + "ticket sync", + "L3", + format!( + "cache {:.0}m stale — fetch may be failing silently", + age / 60.0 + ), + ) + .with_remedy("check the auth row above; the daemon refreshes every 30m"), + Ok(Some(age)) => Check::ok( "ticket sync", "L3", - format!("last synced {:.0}m ago, no errors", age / 60.0), + format!("fresh ({:.0}m ago)", age / 60.0), ), - Ok(Some((None, None))) | Ok(None) => { - Check::info("ticket sync", "L3", "no Jira sync recorded yet") - } + Ok(None) => Check::info("ticket sync", "L3", "no Jira sync recorded yet"), Err(e) => Check::warn( "ticket sync", "L3", @@ -196,72 +188,3 @@ async fn candidate_count(pool: &SqlitePool) -> Check { ), } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::health::Severity; - use sqlx::sqlite::SqliteConnectOptions; - use std::str::FromStr; - - async fn make_db() -> SqlitePool { - let opts = SqliteConnectOptions::from_str("sqlite::memory:") - .unwrap() - .create_if_missing(true); - let pool = SqlitePool::connect_with(opts).await.unwrap(); - sqlx::migrate!("src/migrations").run(&pool).await.unwrap(); - pool - } - - async fn seed(pool: &SqlitePool, synced_modifier: &str, last_error: Option<&str>) { - sqlx::query( - "INSERT INTO pm_sync_state (provider, last_synced_at, last_error) - VALUES ('jira', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ?), ?)", - ) - .bind(synced_modifier) - .bind(last_error) - .execute(pool) - .await - .unwrap(); - } - - /// The false positive this rewrite exists to fix: a sync that hasn't run in - /// hours, with no recorded error, must NOT warn — on-demand syncing means a - /// long quiet period is expected, not a symptom. - #[tokio::test] - async fn sync_freshness_ok_when_stale_but_no_error() { - let pool = make_db().await; - seed(&pool, "-6 hours", None).await; - - let check = sync_freshness(&pool).await; - - assert_eq!(check.severity, Severity::Ok, "detail was: {}", check.detail); - } - - /// A recorded fetch failure is the real signal, regardless of how recent it is. - #[tokio::test] - async fn sync_freshness_warns_on_recorded_error() { - let pool = make_db().await; - seed(&pool, "-2 minutes", Some("401 unauthorized")).await; - - let check = sync_freshness(&pool).await; - - assert_eq!(check.severity, Severity::Warn); - assert!( - check.detail.contains("401 unauthorized"), - "detail was: {}", - check.detail - ); - } - - /// A tracker that has never synced (fresh connect, or one that's never - /// fetched successfully) reports as informational, not a warning. - #[tokio::test] - async fn sync_freshness_info_when_never_synced() { - let pool = make_db().await; - - let check = sync_freshness(&pool).await; - - assert_eq!(check.severity, Severity::Info); - } -} diff --git a/src/intelligence/mod.rs b/src/intelligence/mod.rs index 491d0415c..269728fd4 100644 --- a/src/intelligence/mod.rs +++ b/src/intelligence/mod.rs @@ -3,37 +3,14 @@ pub mod oauth; pub mod providers; pub mod session_categorizer; -/// Producer side of the `pm_sync_requests` outbox for short-lived CLI processes — how -/// `plan-task-*`, `ticket-update` and `worklog-generate` refresh the board without -/// becoming a second writer of the rotating Jira OAuth token. -pub mod sync_delegate; -/// Daemon-side consumer of the `pm_sync_requests` outbox — the reason the daemon is -/// the only process that ever holds the rotating Jira OAuth token. -pub mod sync_requests; pub mod task_triage; pub mod ticket_update; use anyhow::Result; use sqlx::SqlitePool; -use std::sync::OnceLock; -use tokio::sync::Mutex; use crate::config::{Config, PmProviderConfig}; -/// In-process serialisation for [`run_pm_sync`]. Now that syncing is triggered from -/// several independent on-demand call sites (the daily plan, worklog drafting sweep, -/// `meridian pm-sync`) instead of one single poll-loop tick, two of them can land in -/// the same instant — e.g. opening the planner while a drafting sweep is mid-flight. -/// Both would otherwise read `pm_sync_state` as stale and fire a real fetch -/// concurrently, wasting a call for every redundant racer. This only dedups within -/// ONE process; the Jira OAuth refresh-token race across processes is a separate, -/// already-solved problem (`meridian-oauth/src/store.rs::lock_provider`, a -/// cross-process file lock inside the refresh path itself). -fn sync_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) -} - /// True once at least one PM task is cached. Rows only land in `pm_tasks` after a /// provider authenticated and fetched successfully, so a non-zero count is proof /// a tracker actually WORKS (not merely that keys are present — bad creds 401 and @@ -96,74 +73,20 @@ pub async fn run_pm_force_sync(meridian: &SqlitePool, config: &Config) -> Result Ok(()) } -/// Whether a sync is happening behind a real user action or on a clock. Only Jira -/// OAuth cares, and the difference is not cosmetic: see [`Trigger::Unattended`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Trigger { - /// A human just did something — opened the planner, connected a tracker, - /// pressed Sync now, edited a ticket, ran a CLI. The machine is provably awake - /// and in use, so spending the rotating refresh token is safe. - Attended, - /// A clock fired and nobody is necessarily there (the hourly worklog drafting - /// sweep). Jira OAuth must NOT refresh here: the refresh is a single-use - /// exchange whose lost response kills the grant permanently outside a - /// 10-minute window, and a laptop suspending mid-POST is exactly how that - /// happens with no one watching. An expired token means skip this pass; the - /// next attended request refreshes properly. Providers with static - /// credentials (GitHub/Linear/Trello/Azure) are unaffected — they have no - /// rotating credential to lose. - Unattended, -} - -/// Refreshes PM task caches from all configured providers whose cache has gone -/// stale, behind a real user action. See [`run_pm_sync_with`] for the shared body -/// and [`Trigger`] for why the distinction exists. +/// Refreshes PM task caches from all configured providers. #[tracing::instrument(skip_all)] pub async fn run_pm_sync(meridian: &SqlitePool, config: &Config) -> Result<()> { - run_pm_sync_with(meridian, config, Trigger::Attended).await -} - -/// [`run_pm_sync`] for clock-driven callers: refreshes the ticket cache only if it -/// can be done without minting a new Jira OAuth token, and quietly keeps the stale -/// cache otherwise. Used by the hourly worklog drafting sweep. -#[tracing::instrument(skip_all)] -pub async fn run_pm_sync_unattended(meridian: &SqlitePool, config: &Config) -> Result<()> { - run_pm_sync_with(meridian, config, Trigger::Unattended).await -} - -/// Refreshes PM task caches from all configured providers whose cache has gone stale -/// (per-provider `refresh_if_stale`) — a cheap no-op when nothing is stale. Called -/// from every on-demand trigger (the daily plan, worklog drafting, `meridian -/// pm-sync`), not a timer, so [`sync_lock`] serialises same-process callers that land -/// in the same instant rather than each racing the staleness check independently. -#[tracing::instrument(skip(meridian, config))] -pub async fn run_pm_sync_with( - meridian: &SqlitePool, - config: &Config, - trigger: Trigger, -) -> Result<()> { if config.pm_providers.is_empty() { tracing::warn!("no PM providers configured — pm_tasks will stay empty (set JIRA_BASE_URL/GITHUB_TOKEN/LINEAR_API_KEY/AZURE_DEVOPS_PAT)"); return Ok(()); } - - let _guard = match sync_lock().try_lock() { - Ok(g) => g, - Err(_) => { - tracing::debug!("run_pm_sync: another sync is already in flight — waiting for it"); - sync_lock().lock().await - } - }; - let provider_count = config.pm_providers.len(); tracing::debug!(provider_count, "syncing PM providers"); for provider in &config.pm_providers { let name = provider.provider_name(); let result = match provider { - PmProviderConfig::Jira(cfg) => { - providers::jira::refresh_if_stale(meridian, cfg, trigger).await - } + PmProviderConfig::Jira(cfg) => providers::jira::refresh_if_stale(meridian, cfg).await, PmProviderConfig::GitHub(cfg) => { providers::github::refresh_if_stale(meridian, cfg).await } @@ -297,36 +220,4 @@ mod tests { .unwrap(); assert_eq!(queued, 0, "the board hygiene digest producer was removed"); } - - /// `run_pm_sync` now fires from several independent on-demand callers instead of - /// one poll-loop tick, so two of them can land in the same instant. This proves - /// [`sync_lock`] actually serialises concurrent holders rather than being a no-op - /// — tested directly against the lock primitive since exercising the full - /// `run_pm_sync` path needs a live (or mocked) provider HTTP call this crate has - /// no test seam for. - #[tokio::test] - async fn sync_lock_serialises_concurrent_holders() { - let order = std::sync::Arc::new(tokio::sync::Mutex::new(Vec::<&str>::new())); - - let first_guard = sync_lock().lock().await; - let order_clone = order.clone(); - let second = tokio::spawn(async move { - let _g = sync_lock().lock().await; // must wait for `first_guard` to drop - order_clone.lock().await.push("second"); - }); - - // Give the spawned task a chance to actually block on the lock before we - // release it — otherwise a fast scheduler could run it after the drop below - // and the ordering assertion would prove nothing. - tokio::task::yield_now().await; - order.lock().await.push("first"); - drop(first_guard); - - second.await.unwrap(); - assert_eq!( - *order.lock().await, - vec!["first", "second"], - "the second acquirer must not proceed until the first guard drops" - ); - } } diff --git a/src/intelligence/oauth/jira.rs b/src/intelligence/oauth/jira.rs index 2aea4ede5..7af628c62 100644 --- a/src/intelligence/oauth/jira.rs +++ b/src/intelligence/oauth/jira.rs @@ -31,47 +31,29 @@ pub(crate) fn has_basic_auth(jira: &JiraConfig) -> bool { && !jira.api_token.trim().is_empty() } -/// The Basic-auth context from config. Shared by both resolvers so the two cannot -/// disagree about which config fields make up a request context — a field added to -/// [`JiraReqCtx::Basic`] has exactly one place to be filled in. -fn basic_ctx(jira: &JiraConfig) -> JiraReqCtx { - JiraReqCtx::Basic { - base_url: jira.base_url.clone(), - email: jira.email.clone(), - api_token: jira.api_token.clone(), - } -} - -/// The OAuth context from a token the caller has already obtained. Deliberately takes -/// the tokens rather than fetching them: that is the whole difference between the two -/// resolvers ([`resolve`] may mint a new pair, `resolve_unattended` must not), and -/// keeping the fetch out of here means neither can acquire one by accident. -fn oauth_ctx(t: meridian_oauth::store::OAuthTokens) -> JiraReqCtx { - JiraReqCtx::OAuth { - token: t.access_token, - cloud_id: t.cloud_id, - site_url: t.site_url, - } -} - /// Decide how to authenticate Jira requests: prefer the static API token when /// fully configured, otherwise fall back to a stored OAuth session. API token /// beats stored OAuth — a set JIRA_API_TOKEN always wins. /// This mirrors the industry standard (gh, Vercel CLI, Stripe CLI all follow /// env-var-first) and lets developers use a stable PAT in .env without being /// blocked by a stale OAuth session stored in ~/.meridian/oauth/jira.json. -/// -/// **May MINT a new refresh token, so only call this behind a real user action.** -/// See [`resolve_unattended`] for the clock-driven counterpart and why the -/// distinction is load-bearing. pub async fn resolve(jira: &JiraConfig) -> Result { if has_basic_auth(jira) { tracing::debug!(auth_method = "api_token", "resolving Jira auth"); - return Ok(basic_ctx(jira)); + return Ok(JiraReqCtx::Basic { + base_url: jira.base_url.clone(), + email: jira.email.clone(), + api_token: jira.api_token.clone(), + }); } if store::exists("jira") { tracing::debug!(auth_method = "oauth", "resolving Jira auth"); - return Ok(oauth_ctx(ensure_fresh().await?)); + let t = ensure_fresh().await?; + return Ok(JiraReqCtx::OAuth { + token: t.access_token, + cloud_id: t.cloud_id, + site_url: t.site_url, + }); } bail!( "no Jira auth available — run `meridian oauth-login jira`, \ @@ -79,55 +61,6 @@ pub async fn resolve(jira: &JiraConfig) -> Result { ) } -/// [`resolve`] for callers running on a CLOCK rather than behind a user action: -/// returns auth only if it can be had without spending the rotating refresh token. -/// `None` means "skip this pass", never an error. -/// -/// # Why a separate resolver -/// -/// Refreshing an Atlassian OAuth token is a single-use exchange: the old refresh -/// token dies the instant the new one is issued, so a lost response leaves the -/// grant recoverable only inside a 10-minute window. When that POST is fired by a -/// timer with nobody at the machine, a closing laptop lid destroys the grant -/// permanently — which is precisely how a production install lost Jira for five -/// days (refresh at 18:26:55, 28-minute suspend, retry instant on wake at -/// 18:55:29 but 18 minutes too late). -/// -/// So unattended code may USE a valid access token but must never MINT one. This -/// is the same discipline that makes Claude Code's MCP connections durable against -/// the identical protocol: it only ever refreshes on the tail of something a human -/// just asked for. -/// -/// API-token (basic) auth is returned unconditionally — a static token has no -/// expiry to race and no rotating credential to lose, so there is nothing to -/// protect it from. -/// -/// Deferring is close to free: a sweep that finds an expired token is by -/// definition running while nobody is using the machine, so there is little new -/// activity to match, and the next attended request refreshes properly. -/// -/// # Related -/// - [`resolve`] — the attended counterpart, which may refresh. -/// - [`meridian_oauth::jira::current_if_valid`] — the non-minting token read. -pub fn resolve_unattended(jira: &JiraConfig) -> Option { - if has_basic_auth(jira) { - tracing::debug!( - auth_method = "api_token", - "resolving Jira auth (unattended)" - ); - return Some(basic_ctx(jira)); - } - if !store::exists("jira") { - return None; - } - // `current_if_valid`, never `ensure_fresh` — this is the line that makes the - // function unattended-safe, and swapping it would silently reintroduce the - // timer-driven refresh that killed a production grant. - let t = meridian_oauth::jira::current_if_valid()?; - tracing::debug!(auth_method = "oauth", "resolving Jira auth (unattended)"); - Some(oauth_ctx(t)) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/intelligence/providers/jira/refresh.rs b/src/intelligence/providers/jira/refresh.rs index bcc4256e9..dab541b0b 100644 --- a/src/intelligence/providers/jira/refresh.rs +++ b/src/intelligence/providers/jira/refresh.rs @@ -25,7 +25,6 @@ use sqlx::SqlitePool; use crate::config::JiraConfig; use crate::intelligence::providers::http::SyncFault; -use crate::intelligence::Trigger; use super::{ backfill_worklogged, discover_start_date_field, fetch, native_terminal, prune, upsert, @@ -37,11 +36,7 @@ use super::{ // --------------------------------------------------------------------------- #[tracing::instrument(skip(pool, jira))] -pub async fn refresh_if_stale( - pool: &SqlitePool, - jira: &JiraConfig, - trigger: Trigger, -) -> Result>> { +pub async fn refresh_if_stale(pool: &SqlitePool, jira: &JiraConfig) -> Result>> { let threshold = format!("-{SYNC_INTERVAL_MINS} minutes"); let (is_fresh,): (i64,) = sqlx::query_as( "SELECT EXISTS( @@ -75,85 +70,66 @@ pub async fn refresh_if_stale( // Resolve auth once per refresh: OAuth (with refresh-before-use) if a token // store exists, else static basic auth. A resolve failure means no usable // creds — keep the stale cache rather than erroring the whole tick. - // - // A clock-driven pass must never MINT a Jira OAuth token — spending the - // rotating refresh token unattended is what permanently kills a grant when the - // machine suspends mid-POST (see `Trigger::Unattended`). An expired token here - // means keep the stale cache and let the next attended request refresh; that - // is not a failure and must not be recorded as one, or a laptop that was shut - // for the night would raise a sync error every morning. - let ctx = if trigger == Trigger::Unattended { - match crate::intelligence::oauth::jira::resolve_unattended(jira) { - Some(ctx) => ctx, - None => { - tracing::debug!( - "jira token not usable without a refresh - deferring unattended sync" - ); - return Ok(None); - } - } - } else { - match crate::intelligence::oauth::jira::resolve(jira).await { - Ok(ctx) => ctx, - Err(e) => { - // A refresh that failed only because Atlassian was briefly - // unreachable (network blip, timeout, 429, 5xx) does NOT mean the - // token is dead — the stored refresh token is still valid and the - // next tick will almost certainly succeed. Raising a "Reconnect - // Jira / re-run oauth-login" sync error for that transient case is - // exactly what made this fault flap on and off at random. Keep the - // stale cache and stay quiet; the notice is reserved for a terminal - // auth failure the user actually has to act on. - // Deliberately NOT `record_sync_failure` like the fetch arm below: - // this path needs the auth-method-specific remedy override that the - // shared helper has no way to express (basic auth really does want - // the `.env` wording). The classification policy is otherwise - // identical, so a change to one should be mirrored here. - // - // `meridian_oauth::is_transient` only recognises a `TokenError` from - // the token endpoint and answers `false` for anything else, so a raw - // transport failure escaping the refresh would still land here as - // terminal. Falling back to `http::classify` closes that hole - // without ever making a genuinely dead grant look retryable. - let fault = if meridian_oauth::is_transient(&e) { - SyncFault::retry(&e) - } else { - crate::intelligence::providers::http::classify(&e) - }; - match fault { - SyncFault::Retry { detail } => { - tracing::warn!( - error = %detail, - "jira auth temporarily unavailable - keeping stale cache, will retry next sync" - ); - let _ = crate::intelligence::providers::note_transient_sync_failure( - pool, "jira", &detail, - ) - .await; - } - SyncFault::Report { detail } => { - tracing::warn!(error = %detail, "jira auth unavailable - keeping stale cache"); - let msg = format!("Jira auth failed: {detail}"); - // Basic-auth (JIRA_API_TOKEN/JIRA_BASE_URL) and OAuth are - // mutually exclusive here - has_basic_auth() mirrors - // resolve()'s own choice - so the remedy must match whichever - // path this failure came from, not always point at .env. - let remedy = if crate::intelligence::oauth::jira::has_basic_auth(jira) { - "Set JIRA_API_TOKEN and JIRA_BASE_URL in .env" - } else { - "Reconnect Jira in Settings - Integrations" - }; - let _ = crate::intelligence::providers::stamp_sync_error_with_remedy( - pool, - "jira", - &msg, - Some(remedy), - ) - .await; - } + let ctx = match crate::intelligence::oauth::jira::resolve(jira).await { + Ok(ctx) => ctx, + Err(e) => { + // A refresh that failed only because Atlassian was briefly + // unreachable (network blip, timeout, 429, 5xx) does NOT mean the + // token is dead — the stored refresh token is still valid and the + // next tick will almost certainly succeed. Raising a "Reconnect + // Jira / re-run oauth-login" sync error for that transient case is + // exactly what made this fault flap on and off at random. Keep the + // stale cache and stay quiet; the notice is reserved for a terminal + // auth failure the user actually has to act on. + // Deliberately NOT `record_sync_failure` like the fetch arm below: + // this path needs the auth-method-specific remedy override that the + // shared helper has no way to express (basic auth really does want + // the `.env` wording). The classification policy is otherwise + // identical, so a change to one should be mirrored here. + // + // `meridian_oauth::is_transient` only recognises a `TokenError` from + // the token endpoint and answers `false` for anything else, so a raw + // transport failure escaping the refresh would still land here as + // terminal. Falling back to `http::classify` closes that hole + // without ever making a genuinely dead grant look retryable. + let fault = if meridian_oauth::is_transient(&e) { + SyncFault::retry(&e) + } else { + crate::intelligence::providers::http::classify(&e) + }; + match fault { + SyncFault::Retry { detail } => { + tracing::warn!( + error = %detail, + "jira auth temporarily unavailable - keeping stale cache, will retry next sync" + ); + let _ = crate::intelligence::providers::note_transient_sync_failure( + pool, "jira", &detail, + ) + .await; + } + SyncFault::Report { detail } => { + tracing::warn!(error = %detail, "jira auth unavailable - keeping stale cache"); + let msg = format!("Jira auth failed: {detail}"); + // Basic-auth (JIRA_API_TOKEN/JIRA_BASE_URL) and OAuth are + // mutually exclusive here - has_basic_auth() mirrors + // resolve()'s own choice - so the remedy must match whichever + // path this failure came from, not always point at .env. + let remedy = if crate::intelligence::oauth::jira::has_basic_auth(jira) { + "Set JIRA_API_TOKEN and JIRA_BASE_URL in .env" + } else { + "Reconnect Jira in Settings - Integrations" + }; + let _ = crate::intelligence::providers::stamp_sync_error_with_remedy( + pool, + "jira", + &msg, + Some(remedy), + ) + .await; } - return Ok(None); } + return Ok(None); } }; let auth_method = if jira.api_token.is_empty() { @@ -249,16 +225,10 @@ pub async fn refresh_if_stale( /// Clears `pm_sync_state` for this provider so `refresh_if_stale` sees it as /// stale, then delegates. The `last_synced_at` is updated inside the delegate, /// so subsequent ticks won't double-fetch. -/// -/// Always [`Trigger::Attended`]: every caller is a direct user action — connecting -/// a tracker, pressing Sync now, or a `meridian tasks-sync` / `ticket-update` CLI -/// invocation — so refreshing the OAuth token here is safe and expected. There is -/// deliberately no unattended force-sync: forcing a fetch while nobody is present -/// is the combination this whole distinction exists to prevent. pub async fn force_refresh(pool: &SqlitePool, jira: &JiraConfig) -> Result>> { sqlx::query("DELETE FROM pm_sync_state WHERE provider = 'jira'") .execute(pool) .await .context("clearing jira sync state for force refresh")?; - refresh_if_stale(pool, jira, Trigger::Attended).await + refresh_if_stale(pool, jira).await } diff --git a/src/intelligence/sync_delegate.rs b/src/intelligence/sync_delegate.rs deleted file mode 100644 index 8475390f9..000000000 --- a/src/intelligence/sync_delegate.rs +++ /dev/null @@ -1,320 +0,0 @@ -//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity -//! Producer side of the `pm_sync_requests` outbox for **short-lived CLI processes**: -//! ask the daemon to sync, rather than syncing here. -//! -//! # Why this exists -//! -//! An Atlassian OAuth refresh token is single-use and rotating: the old token dies the -//! instant the new one is issued, so a lost response leaves the grant recoverable only -//! inside a 10-minute window and permanently dead after it. A credential like that has -//! exactly one safe writer, and [`crate::intelligence::sync_requests`] makes the daemon -//! that writer. -//! -//! The tray was converted to request-instead-of-sync at the same time, but six CLI -//! subcommands the tray *spawns* were missed and kept refreshing in their own process: -//! `plan-task-create` / `plan-task-edit` / `plan-task-done`, `ticket-update`, -//! `ticket-set-status`, and `worklog-generate`. Every one is user-triggered, so none of -//! them can recreate the timer-driven "refresh POST in flight when the lid closes" -//! failure - but each is a second process able to spend the token while the daemon's -//! watcher is servicing a request. The only thing serialising them was -//! `meridian-oauth`'s advisory file lock, whose 10 s timeout is shorter than the ~26 s -//! a refresh can take and which proceeds WITHOUT the lock on timeout. This closes that. -//! -//! # Why the in-process fallback stays -//! -//! A dev checkout, CI, or a support session with the daemon stopped still needs these -//! commands to refresh the board. With no daemon running there is no second writer, so -//! syncing here is safe by the same argument. [`crate::platform::daemon_already_running`] -//! is the same probe the single-instance guard uses, so the two cannot disagree about -//! who owns the data dir. -//! -//! # Who calls this -//! - `src/main.rs`'s `tasks-sync` / `pm-sync` / `ticket-update` / `ticket-set-status` / -//! `worklog-generate` arms. -//! - [`crate::plan_tasks`]'s `create` / `edit` / `done` post-write refresh. -//! -//! # Related -//! - [`meridian_core::pm_sync_requests`] - the request/claim/complete API. -//! - [`crate::intelligence::sync_requests`] - the daemon-side consumer that does the work. -//! - [`crate::intelligence::Trigger`] - why attendedness is tracked at all. - -use std::time::Duration; - -use meridian_core::pm_sync_requests::{self, SyncMode, ALL_PROVIDERS}; -use sqlx::SqlitePool; - -use crate::config::Config; - -/// How often to re-read the request row while waiting. Matched to the daemon watcher's -/// own 2 s tick rather than being tighter - a faster poll cannot see a result sooner. -const POLL_INTERVAL: Duration = Duration::from_millis(500); - -/// What happened to a delegated sync. -/// -/// `Pending` deliberately merges "queued, not waited for" and "waited, budget elapsed": -/// both mean the daemon owns the work and will finish it, which is the only thing a -/// caller can act on. Neither is a failure. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Delegation { - /// The sync finished successfully. `count` is the resulting `pm_tasks` total, and is - /// present only when the daemon reported it (the in-process path does not tally). - Synced { count: Option }, - /// The sync was attempted and failed, or the request could not be queued. The string - /// is already a flattened error chain, ready to print or log. - Failed { error: String }, - /// Queued for the daemon; no outcome is known yet. - Pending, -} - -/// Ask for a sync and wait up to `budget` for the outcome. -/// -/// Use this when the caller's next step *reads* `pm_tasks` and would behave differently -/// against a stale board - `plan-task-create` checking whether its new ticket has -/// mirrored, or `worklog-generate` matching sessions to tickets. Pick a budget that -/// fits inside the caller's own timeout: a `Pending` return means "carry on with what -/// is cached", never "fail". -pub async fn sync_and_wait( - pool: &SqlitePool, - config: &Config, - mode: SyncMode, - label: &str, - budget: Duration, -) -> Delegation { - delegate(pool, config, mode, label, Some(budget)).await -} - -/// The budget for a post-write refresh (`plan-task-done`, `plan-task-edit`, -/// `ticket-update`, `ticket-set-status`). -/// -/// These deliberately WAIT rather than firing and forgetting, because they were -/// synchronous before delegation and the frontend re-reads the board as soon as the CLI -/// exits - returning early would show the pre-write value for a second or two and read -/// as "my change didn't save". The old inline path already paid this latency (its own -/// HTTP call, up to ~26 s with a token refresh), so waiting is not a new cost. -pub const POST_WRITE_SYNC_BUDGET: Duration = Duration::from_secs(30); - -/// Ask for a post-write refresh and wait for it, using [`POST_WRITE_SYNC_BUDGET`]. -/// -/// A `Pending` return is not a failure: the tracker write already landed and the daemon -/// will still finish the mirror refresh. Callers log it and carry on. -pub async fn sync_after_write(pool: &SqlitePool, config: &Config, label: &str) -> Delegation { - delegate( - pool, - config, - SyncMode::Force, - label, - Some(POST_WRITE_SYNC_BUDGET), - ) - .await -} - -/// Shared body: pick the owner, then act. -/// -/// Only the branch lives here - both halves are separately testable, which the probe -/// makes necessary: [`crate::platform::daemon_already_running`] talks to the real -/// single-instance endpoint, so a test that called this function would pass or fail -/// depending on whether the developer happens to have a daemon running. -#[tracing::instrument(skip(pool, config), fields(mode = mode.as_str()))] -async fn delegate( - pool: &SqlitePool, - config: &Config, - mode: SyncMode, - label: &str, - wait: Option, -) -> Delegation { - if crate::platform::daemon_already_running().await { - request_and_wait(pool, mode, label, wait).await - } else { - sync_here(pool, config, mode, label).await - } -} - -/// The no-daemon fallback: do the sync in this process. -async fn sync_here(pool: &SqlitePool, config: &Config, mode: SyncMode, label: &str) -> Delegation { - tracing::debug!(label, "no daemon running - syncing in-process"); - let result = match mode { - SyncMode::Force => super::run_pm_force_sync(pool, config).await, - SyncMode::Gated => super::run_pm_sync(pool, config).await, - }; - match result { - Ok(()) => Delegation::Synced { count: None }, - Err(e) => Delegation::Failed { - error: crate::errors::chain(&e), - }, - } -} - -/// The delegated path: hand the work to the daemon, optionally waiting for its outcome. -/// `wait: None` returns [`Delegation::Pending`] the moment the request is written. -async fn request_and_wait( - pool: &SqlitePool, - mode: SyncMode, - label: &str, - wait: Option, -) -> Delegation { - if let Err(e) = pm_sync_requests::request(pool, ALL_PROVIDERS, mode, label).await { - return Delegation::Failed { - error: format!( - "could not queue the sync request: {}", - crate::errors::chain(&e) - ), - }; - } - tracing::debug!(label, "pm sync requested - the daemon owns tracker auth"); - - match wait { - Some(budget) => wait_for_outcome(pool, label, budget).await, - None => Delegation::Pending, - } -} - -/// Poll the request row until the daemon records an outcome, or `budget` elapses. -/// -/// The row is keyed on the provider (`'*'`), so a concurrent CLI's request can reset it -/// and this can end up reading a sibling's outcome. That is fine: both asked for the same -/// thing, and "some sync just completed" is exactly what the caller needs to know. It -/// cannot read a *stale* outcome, because `request` clears `completed_at`. -async fn wait_for_outcome(pool: &SqlitePool, label: &str, budget: Duration) -> Delegation { - let deadline = std::time::Instant::now() + budget; - loop { - if std::time::Instant::now() >= deadline { - tracing::debug!( - label, - budget_s = budget.as_secs(), - "daemon did not report a sync outcome in time - continuing" - ); - return Delegation::Pending; - } - tokio::time::sleep(POLL_INTERVAL).await; - match pm_sync_requests::outcome(pool, ALL_PROVIDERS).await { - Ok(Some(out)) => { - return match out.error { - Some(error) => Delegation::Failed { error }, - None => Delegation::Synced { - count: out.synced_count, - }, - }; - } - Ok(None) => continue, - Err(e) => { - return Delegation::Failed { - error: format!( - "could not read the sync outcome: {}", - crate::errors::chain(&e) - ), - }; - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use sqlx::sqlite::SqliteConnectOptions; - use std::str::FromStr; - - async fn db() -> SqlitePool { - let opts = SqliteConnectOptions::from_str("sqlite::memory:") - .unwrap() - .create_if_missing(true); - let pool = SqlitePool::connect_with(opts).await.unwrap(); - sqlx::migrate!("src/migrations").run(&pool).await.unwrap(); - pool - } - - /// With no providers configured, `run_pm_*_sync` returns early - so the fallback - /// path must report success rather than queueing anything. If it ever starts writing - /// a request row, a dev checkout would silently stop refreshing: with no daemon - /// there is nothing to service it. - #[tokio::test] - async fn the_fallback_syncs_here_and_queues_nothing() { - let pool = db().await; - let cfg = Config::from_env(); - - let got = sync_here(&pool, &cfg, SyncMode::Force, "test").await; - - assert_eq!(got, Delegation::Synced { count: None }); - assert!( - pm_sync_requests::outcome(&pool, ALL_PROVIDERS) - .await - .unwrap() - .is_none(), - "the in-process path must not queue a request" - ); - } - - /// A queued request must be *claimable* by the daemon, carrying the mode and reason - /// the producer asked for. If the row were written in a shape `claim` cannot match, - /// every delegated sync would silently never happen. - #[tokio::test] - async fn a_queued_request_is_claimable_by_the_daemon() { - let pool = db().await; - - let got = request_and_wait(&pool, SyncMode::Force, "plan-task-done", None).await; - - assert_eq!(got, Delegation::Pending); - let claimed = pm_sync_requests::claim(&pool, ALL_PROVIDERS) - .await - .unwrap() - .expect("the daemon must be able to claim the queued request"); - assert_eq!(claimed.mode, SyncMode::Force); - assert_eq!(claimed.reason, "plan-task-done"); - } - - /// A budget that elapses with no daemon to service the row must read as `Pending`, - /// not `Failed`. `plan-task-create` and `worklog-generate` both continue on - /// `Pending` and would otherwise log a phantom failure on every slow sync. - #[tokio::test] - async fn an_elapsed_budget_is_pending_not_failed() { - let pool = db().await; - - let got = request_and_wait( - &pool, - SyncMode::Gated, - "worklog-generate", - Some(Duration::from_millis(600)), - ) - .await; - - assert_eq!(got, Delegation::Pending); - } - - /// A failure the daemon recorded must reach the caller verbatim, so `tasks-sync` - /// exits non-zero with the real reason rather than a generic timeout. - #[tokio::test] - async fn a_recorded_failure_is_reported_to_the_caller() { - let pool = db().await; - pm_sync_requests::request(&pool, ALL_PROVIDERS, SyncMode::Force, "tasks-sync") - .await - .unwrap(); - pm_sync_requests::claim(&pool, ALL_PROVIDERS).await.unwrap(); - pm_sync_requests::complete(&pool, ALL_PROVIDERS, None, Some("refresh_token is invalid")) - .await - .unwrap(); - - // Re-requesting clears the outcome, so the waiter must be the one to observe it: - // drive the wait directly against the already-completed row. - let got = wait_for_outcome(&pool, "tasks-sync", Duration::from_secs(5)).await; - - assert_eq!( - got, - Delegation::Failed { - error: "refresh_token is invalid".to_string() - } - ); - } - - /// `Pending` is not a failure, and callers branch on that. Pinned because the - /// obvious refactor - folding a timeout into `Failed` - would turn "the daemon is - /// still working" into a user-visible error on every slow sync. - #[test] - fn pending_is_distinct_from_failed() { - assert_ne!( - Delegation::Pending, - Delegation::Failed { - error: "x".to_string() - } - ); - } -} diff --git a/src/intelligence/sync_requests.rs b/src/intelligence/sync_requests.rs deleted file mode 100644 index 8e1838909..000000000 --- a/src/intelligence/sync_requests.rs +++ /dev/null @@ -1,227 +0,0 @@ -//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity -//! Single-owner PM sync: the daemon-side consumer of the `pm_sync_requests` outbox. -//! -//! # Why this task exists -//! -//! An Atlassian OAuth refresh token is single-use and rotating: the old token dies -//! the instant the new one is issued, so a lost response leaves the grant -//! recoverable only inside a 10-minute window and permanently dead after it. A -//! credential like that has exactly one safe writer. -//! -//! It used to have many. The tray refreshed in-process, the daemon refreshed on its -//! poll loop, and the tray spawned fresh `meridian pm-sync` / `tasks-sync` processes -//! that each refreshed too. The advisory file lock meant to serialise them could not: -//! its 10 s timeout is shorter than the ~26 s a refresh can take, and on timeout the -//! code proceeded WITHOUT the lock. Two processes could spend the same token, and -//! only Atlassian's grace window kept that from corrupting state. -//! -//! This watcher makes the daemon the sole consumer, so single-ownership is a property -//! of the architecture rather than of lock discipline. Every other would-be writer - -//! the tray, and the `tasks-sync` / `pm-sync` / `plan-task-*` / `ticket-update` / -//! `ticket-set-status` / `worklog-generate` CLIs - now writes a request row instead -//! (see [`crate::intelligence::sync_delegate`]). The only remaining in-process sync is -//! the fallback taken when no daemon is running at all, where there is no second -//! writer to race. -//! -//! # Why a separate task instead of the main poll loop -//! -//! The poll loop ticks on `POLL_INTERVAL_SECS` (60 s by default). Making a user who -//! pressed "Sync now" wait up to a minute for the sync to even *begin* would be a -//! visible regression against the old shell-out, which started immediately. So this -//! runs its own short cadence ([`WATCH_INTERVAL`]) - a single indexed read against a -//! local SQLite file, negligible next to the ETL work sharing the process. -//! -//! It is deliberately NOT a timer that syncs on its own: it only ever acts on a row -//! a producer wrote, so every refresh still traces back to a human action. That is -//! the property that stops a refresh POST being in flight when a laptop lid closes. -//! -//! # Who calls this -//! - [`run_watcher`] is spawned once by `src/main.rs` at daemon startup. -//! - Producers write rows via [`meridian_core::pm_sync_requests::request`]. -//! -//! # Related -//! - [`meridian_core::pm_sync_requests`] - the request/claim/complete API. -//! - [`crate::intelligence::run_pm_sync`] / [`crate::intelligence::run_pm_force_sync`] -//! - the work this dispatches to. -//! - [`crate::intelligence::Trigger`] - why attendedness is tracked at all. - -use std::time::Duration; - -use meridian_core::pm_sync_requests::{self, SyncMode, ALL_PROVIDERS}; -use sqlx::SqlitePool; -use tokio::sync::watch; - -use crate::config::Config; - -/// How often to look for a pending request. Short enough that "Sync now" feels -/// immediate, and cheap enough to be irrelevant: one indexed read of a single-row -/// table on a local file. -const WATCH_INTERVAL: Duration = Duration::from_secs(2); - -/// Drain PM sync requests for the life of the process. -/// -/// Releases claims stranded by a previous daemon exit once at startup (see -/// [`pm_sync_requests::reset_stale_claims`]), then loops. -/// -/// Never propagates an error: a failure to read the table, or a failing sync, must -/// not take down the daemon or stop future requests being serviced. Outcomes are -/// recorded on the row so a producer can surface them. -/// -/// Returns when `shutdown_rx` goes true. The shutdown check is on the SLEEP, not -/// mid-sync, so a sync already in flight is allowed to finish and record its -/// outcome rather than being cut off with the row left claimed. A hard kill during a -/// sync is still possible, and [`pm_sync_requests::reset_stale_claims`] cleans that -/// up on the next boot. -#[tracing::instrument(skip(pool, shutdown_rx))] -pub async fn run_watcher(pool: SqlitePool, mut shutdown_rx: watch::Receiver) { - if let Err(e) = pm_sync_requests::reset_stale_claims(&pool).await { - // Non-fatal: the table may not exist yet on a very old DB mid-migration. - // A stranded claim self-heals as soon as any producer writes a new request - // (which resets `claimed_at`), so this is a latency issue, not a dead end. - tracing::warn!( - error = %crate::errors::chain(&e), - "could not release stale PM sync claims - a pending request may wait for the next producer" - ); - } - - loop { - tokio::select! { - _ = tokio::time::sleep(WATCH_INTERVAL) => service_once(&pool).await, - _ = shutdown_rx.changed() => { - if *shutdown_rx.borrow() { - tracing::debug!("PM sync request watcher stopping"); - return; - } - } - } - } -} - -/// Claim and service at most one pending request. Split from the loop so the -/// decision logic is reachable without spawning a task. -async fn service_once(pool: &SqlitePool) { - let req = match pm_sync_requests::claim(pool, ALL_PROVIDERS).await { - Ok(Some(req)) => req, - Ok(None) => return, - Err(e) => { - // Logged at debug, not warn: on a fresh install this fires every 2 s - // until migration 082 has run, and a warn-level line every 2 s would - // bury real problems (and, being WARN+, would egress to central - // observability on every packaged install). - tracing::debug!( - error = %crate::errors::chain(&e), - "could not read PM sync requests" - ); - return; - } - }; - - // Config is read here rather than captured at spawn time so a settings change - // (a newly connected tracker, an edited JQL) is picked up without a restart. - // Cheap because this only runs when there is actually work. - let cfg = Config::from_env(); - - tracing::info!( - mode = req.mode.as_str(), - reason = %req.reason, - "servicing PM sync request" - ); - - // Both arms are ATTENDED: a row exists only because a producer wrote it in - // response to a user action, which is precisely the condition that makes - // spending the rotating refresh token safe. There is deliberately no unattended - // path through this watcher - the clock-driven worklog sweep calls - // `run_pm_sync_unattended` directly instead of requesting. - let result = match req.mode { - SyncMode::Force => crate::intelligence::run_pm_force_sync(pool, &cfg).await, - SyncMode::Gated => crate::intelligence::run_pm_sync(pool, &cfg).await, - }; - - let (count, error) = match result { - Ok(()) => { - // The count is read back from `pm_tasks` rather than threaded out of the - // sync: `run_pm_sync` fans out over every provider and a per-provider - // tally would have to pick one to report. The board total is what "Sync - // now" actually wants to show. - let n = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM pm_tasks") - .fetch_one(pool) - .await - .ok(); - tracing::info!(task_count = ?n, "PM sync request completed"); - (n, None) - } - Err(e) => { - let detail = crate::errors::chain(&e); - tracing::warn!(error = %detail, "PM sync request failed"); - (None, Some(detail)) - } - }; - - if let Err(e) = pm_sync_requests::complete(pool, &req.provider, count, error.as_deref()).await { - tracing::warn!( - error = %crate::errors::chain(&e), - "could not record the PM sync outcome - the producer will keep waiting" - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use sqlx::sqlite::SqliteConnectOptions; - use std::str::FromStr; - - async fn db() -> SqlitePool { - let opts = SqliteConnectOptions::from_str("sqlite::memory:") - .unwrap() - .create_if_missing(true); - let pool = SqlitePool::connect_with(opts).await.unwrap(); - sqlx::migrate!("src/migrations").run(&pool).await.unwrap(); - pool - } - - /// An empty table must be a silent no-op. This runs every 2 s forever, so any - /// noise or error here would be a permanent log flood. - #[tokio::test] - async fn service_once_is_quiet_with_no_requests() { - let pool = db().await; - service_once(&pool).await; - assert!(pm_sync_requests::outcome(&pool, ALL_PROVIDERS) - .await - .unwrap() - .is_none()); - } - - /// With no PM providers configured, `run_pm_sync` returns `Ok(())` early, so the - /// request must still be marked complete rather than left pending forever - a - /// producer polling for the outcome would otherwise hang. - #[tokio::test] - async fn a_request_is_completed_even_with_no_providers_configured() { - let pool = db().await; - pm_sync_requests::request(&pool, ALL_PROVIDERS, SyncMode::Gated, "test") - .await - .unwrap(); - - service_once(&pool).await; - - let out = pm_sync_requests::outcome(&pool, ALL_PROVIDERS) - .await - .unwrap() - .expect("the request must be completed, not left pending"); - assert!(out.error.is_none(), "unexpected error: {:?}", out.error); - } - - /// The migration must actually create the table the watcher depends on. - #[tokio::test] - async fn migration_creates_the_requests_table() { - let pool = db().await; - let exists: i64 = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM sqlite_master - WHERE type = 'table' AND name = 'pm_sync_requests')", - ) - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(exists, 1); - } -} diff --git a/src/main.rs b/src/main.rs index 157153b91..f52889729 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,9 +8,8 @@ use anyhow::{Context, Result}; use meridian::config::Config; use meridian::db::meridian::{cleanup_incomplete_runs, setup_db}; use meridian::etl::run_etl; -use meridian::intelligence::sync_delegate::Delegation; +use meridian::intelligence::{run_pm_force_sync, run_pm_sync}; use meridian::observability; -use meridian_core::pm_sync_requests::SyncMode; use tokio::sync::Notify; use tracing::Instrument; @@ -351,45 +350,23 @@ async fn main() -> Result<()> { return Ok(()); } - // `meridian tasks-sync` (force) / `meridian pm-sync` (gated) — the two on-demand - // CLI sync entry points. There is no background poller anymore; see - // `src/intelligence/mod.rs`'s doc comments for the full trigger list. - // - // `tasks-sync` bypasses the per-provider staleness gate (the user asked for fresh - // data now); `pm-sync` honours it, so it is a cheap no-op on an already-fresh - // board. Both DELEGATE to a running daemon rather than syncing here — see - // `cli_sync` for why the rotating credential has exactly one safe writer. - // - // One arm, because the two differ only in mode and label. They were separate - // copies of the same fourteen lines, which is how `pm-sync` ended up documented as - // the command the tray used "on opening the dashboard" — a trigger that no longer - // exists. - // - // Exit 1 if the DB cannot be opened, or if the sync itself failed. Reporting the - // sync failure in the exit code matters: with no daemon running the tray's "Sync - // now" shells out to `tasks-sync` and reads a non-zero exit as the failure, so - // exiting 0 here would show the user a successful sync that did not happen. A - // timeout is NOT a failure (`cli_sync` returns true) — the daemon is still working. - let cli_sync_mode = match std::env::args().nth(1).as_deref() { - Some("tasks-sync") => Some((SyncMode::Force, "tasks-sync")), - Some("pm-sync") => Some((SyncMode::Gated, "pm-sync")), - _ => None, - }; - if let Some((mode, label)) = cli_sync_mode { + // `meridian tasks-sync` — force an immediate sync of all configured PM + // providers (Jira, Linear, GitHub), bypassing the 5-minute staleness gate. + // Exits 0 on success, non-zero if the DB cannot be opened. + if std::env::args().nth(1).as_deref() == Some("tasks-sync") { let cfg = Config::from_env(); match setup_db(&cfg.meridian_db_uri()).await { Ok(pool) => { - let ok = cli_sync(&pool, &cfg, mode, label).await; - pool.close().await; - if !ok { - std::process::exit(1); + if let Err(e) = run_pm_force_sync(&pool, &cfg).await { + eprintln!("tasks-sync: {e}"); } + pool.close().await; } Err(e) => { // `{e:#}` prints the full anyhow source chain (e.g. the sqlx // "migration N was previously applied / missing" cause) instead // of just the top-level "failed to run migrations" context. - eprintln!("{label}: open db: {e:#}"); + eprintln!("tasks-sync: open db: {e:#}"); std::process::exit(1); } } @@ -427,7 +404,7 @@ async fn main() -> Result<()> { meridian::intelligence::ticket_update::ApplyStatus::Applied ) { if let Ok(pool) = setup_db(&cfg.meridian_db_uri()).await { - cli_sync_after_write(&pool, &cfg, "ticket-update").await; + let _ = run_pm_force_sync(&pool, &cfg).await; pool.close().await; } } @@ -533,7 +510,7 @@ async fn main() -> Result<()> { meridian::intelligence::ticket_update::ApplyStatus::Applied ) { if let Ok(pool) = setup_db(&cfg.meridian_db_uri()).await { - cli_sync_after_write(&pool, &cfg, "ticket-set-status").await; + let _ = run_pm_force_sync(&pool, &cfg).await; pool.close().await; } } @@ -717,32 +694,6 @@ async fn main() -> Result<()> { let obs_guard = observability::init("meridian-rust").ok(); match setup_db(&cfg.meridian_db_uri()).await { Ok(pool) => { - // Matching needs current ticket state — a stale board could silently - // bind this draft to a closed/renamed ticket. Best-effort: a sync - // failure must not block drafting against whatever's cached. - // - // Delegated to the daemon, the sole owner of the rotating Jira OAuth - // token (see `intelligence::sync_delegate`). The wait budget is short - // because an LLM call follows inside the tray's 150 s timeout for this - // command: better to draft against a slightly stale board than to blow - // the timeout and show the user nothing at all. - match meridian::intelligence::sync_delegate::sync_and_wait( - &pool, - &cfg, - SyncMode::Gated, - "worklog-generate", - std::time::Duration::from_secs(30), - ) - .await - { - Delegation::Synced { .. } => {} - Delegation::Failed { error } => { - tracing::warn!(%error, "worklog-generate: pm sync failed — matching against cached tasks"); - } - Delegation::Pending => { - tracing::warn!("worklog-generate: pm sync still running — matching against cached tasks"); - } - } match meridian::pm_worklog::generate(&pool, &cfg, &day, &task_id).await { Ok(mut draft) => { // A matched draft carries a target_key we can link even @@ -1404,7 +1355,9 @@ async fn main() -> Result<()> { } // 7c. Run ETL once immediately before entering the loop. + // Re-read config so that any settings.json present at startup takes effect. { + let cfg = Config::from_env(); let startup_tick = tracing::info_span!("startup_tick"); *etl_tick_span.lock().unwrap_or_else(|e| e.into_inner()) = Some(startup_tick.clone()); let _guard = startup_tick.enter(); @@ -1428,10 +1381,12 @@ async fn main() -> Result<()> { tracing::warn!(error = %meridian::errors::chain(&e), "capture retention sweep failed"); } } - // No PM sync here anymore — there's no background poller. Syncing is - // on-demand now (the daily plan, the match-to-ticket picker, connecting a - // tracker, a board write, worklog drafting, `meridian tasks-sync`/`pm-sync`); - // see `src/intelligence/mod.rs`'s doc comments for the full set of triggers. + if let Err(e) = run_pm_sync(&meridian, &cfg).await { + tracing::error!( + error = %meridian::errors::chain(&e), + "intelligence run failed" + ); + } } let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); @@ -1516,32 +1471,6 @@ async fn main() -> Result<()> { }); } - // 7h. PM sync request watcher — the daemon is the SOLE holder of the rotating - // Jira OAuth refresh token, so every other process (the tray, the CLIs) asks - // for a sync by writing a `pm_sync_requests` row instead of refreshing - // itself. This drains those requests. - // - // Why single-ownership matters: the refresh is a single-use exchange whose - // lost response kills the grant outside a 10-minute window. Serialising N - // processes with an advisory file lock did not work — a 10 s lock timeout - // guarding a ~26 s operation, and on timeout the code proceeded WITHOUT the - // lock — so two processes could spend the same token and only Atlassian's - // grace window prevented corruption. - // - // Deliberately its own task on a short cadence rather than folded into the - // 60 s poll loop below: a user pressing "Sync now" must not wait up to a - // minute for the sync to begin. It never syncs on its own initiative — only - // ever on a row a producer wrote, so every refresh still traces to a human - // action, which is what keeps a refresh POST from being in flight when a - // laptop lid closes. - { - let pool_sync = meridian.clone(); - let rx_sync = shutdown_rx.clone(); - tokio::spawn(async move { - meridian::intelligence::sync_requests::run_watcher(pool_sync, rx_sync).await; - }); - } - // 8b. Poll loop — ETL, PM sync, and FM categorization on the configured interval. // Track the last-applied log level so we can detect changes and hot-reload // the EnvFilter without restarting the daemon. @@ -1676,16 +1605,17 @@ async fn main() -> Result<()> { tracing::debug!(error = %meridian::errors::chain(&e), "notification response consume skipped"); } - // No PM sync on this tick anymore — there's no background poller. - // `pm_tasks` is refreshed on-demand instead: the daily plan and the - // match-to-ticket picker (the two screens that decide something from - // the whole board), connecting a tracker, any board write, the worklog - // drafting sweep (`pm_worklog::auto_generate`), and the manual - // `meridian tasks-sync`/`pm-sync` CLI paths. See - // `src/intelligence/mod.rs`'s doc comments for the full trigger list - // and the reasoning (fewer standing background refreshes also shrinks - // how often a Jira OAuth token refresh can straddle a laptop - // sleep/wake). + // Refresh the PM task cache (pm_tasks) every tick — interval-gated + // per provider (~5 min), so this is a cheap no-op most ticks. The + // legacy drafting driver that used to trigger this before every + // pass was retired when the worklog pipeline moved to the + // clock-aligned Python trigger, which never calls this itself — + // leaving pm_tasks (and hence a ticket's title on the timeline) + // stuck at whatever it was at the last daemon restart. This is + // the only thing that keeps it live during normal operation. + if let Err(e) = run_pm_sync(&meridian, &cfg).await { + tracing::warn!(error = %meridian::errors::chain(&e), "pm_tasks refresh failed — using cached tasks"); + } } } } @@ -1732,76 +1662,6 @@ async fn main() -> Result<()> { Ok(()) } -/// Body of the `tasks-sync` / `pm-sync` CLIs: ask the daemon and report what it did. -/// -/// The delegation itself (and why a CLI must not spend the rotating Jira OAuth token -/// itself) lives in [`meridian::intelligence::sync_delegate`]; this only maps the -/// outcome onto stdout/stderr and an exit code, so the user-facing wording stays in -/// one place next to the other CLI output. -async fn cli_sync( - pool: &meridian::db::SqlitePool, - cfg: &Config, - mode: SyncMode, - label: &str, -) -> bool { - // A generous budget: a cold sync across five providers with a token refresh can - // legitimately take a while, and a CLI that gives up early looks like a failure - // when the sync is still going. Syncing IS the point of this command, so unlike - // the post-write callers it waits. - match meridian::intelligence::sync_delegate::sync_and_wait( - pool, - cfg, - mode, - label, - std::time::Duration::from_secs(120), - ) - .await - { - Delegation::Synced { count: Some(n) } => { - println!("{label}: synced {n} task(s)"); - true - } - Delegation::Synced { count: None } => { - println!("{label}: synced"); - true - } - Delegation::Failed { error } => { - eprintln!("{label}: {error}"); - false - } - // NOT an error exit: the request is queued and the daemon will service it. - // Exiting non-zero here would fail scripts over a slow sync that ultimately - // succeeds. - Delegation::Pending => { - println!("{label}: still running - it will finish in the background"); - true - } - } -} - -/// Refresh the board after a CLI write applied to the tracker (`ticket-update`, -/// `ticket-set-status`). -/// -/// Waits for the outcome (see `sync_delegate::POST_WRITE_SYNC_BUDGET`): the frontend -/// re-reads the board as soon as this process exits, so returning before the mirror -/// caught up would briefly show the pre-write value and read as a lost edit. Failures -/// and timeouts are logged, never printed - the tracker write already succeeded, so a -/// sync hiccup must not make the command look like it failed. -async fn cli_sync_after_write(pool: &meridian::db::SqlitePool, cfg: &Config, label: &str) { - match meridian::intelligence::sync_delegate::sync_after_write(pool, cfg, label).await { - Delegation::Synced { .. } => {} - Delegation::Failed { error } => { - tracing::warn!(label, %error, "post-write pm sync failed - the tracker write still landed"); - } - Delegation::Pending => { - tracing::warn!( - label, - "post-write pm sync still running - the tracker write still landed" - ); - } - } -} - /// Runs one ETL pass and maps the outcome onto the notice bus. /// /// Returns `true` when the failure was database corruption, which the caller diff --git a/src/migrations/082_pm_sync_requests.sql b/src/migrations/082_pm_sync_requests.sql deleted file mode 100644 index 9a49eaaeb..000000000 --- a/src/migrations/082_pm_sync_requests.sql +++ /dev/null @@ -1,68 +0,0 @@ --- ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity --- --- Single-owner PM sync: the request side of the outbox. --- --- WHY THIS EXISTS --- An Atlassian OAuth refresh token is single-use and rotating: the old token dies --- the instant the new one is issued, so a lost response leaves the grant --- recoverable only inside a 10-minute window and unrecoverable after it. That --- makes the token a resource with exactly one safe writer. --- --- It had several. The tray refreshed in-process, the daemon refreshed on its poll --- loop, and the tray spawned `meridian pm-sync` / `tasks-sync` as fresh processes --- that each refreshed too. Mutual exclusion was left to an advisory file lock that --- could not actually deliver it: its 10 s timeout is shorter than the ~26 s a --- refresh can take (3 attempts x 8 s + backoff), and on failure the code proceeded --- WITHOUT the lock rather than backing off. Two processes could therefore spend the --- same token, and the only thing preventing corruption was Atlassian's grace window --- handing the loser the current pair. Correctness by vendor accident. --- --- So sync becomes a REQUEST rather than an action. Producers (tray window opens, --- tracker connect, "Sync now", the CLI) write a row here; the daemon is the sole --- consumer and the sole holder of the credential. --- --- COALESCING, NOT A QUEUE --- `provider` is the PRIMARY KEY so repeated requests collapse into one pending row --- (`ON CONFLICT DO UPDATE`). Opening the dashboard ten times must not queue ten --- syncs - it must mean "a sync is wanted", once. `'*'` is the all-providers request --- that every current producer writes; per-provider rows are reserved for a future --- caller that needs to refresh just one board. --- --- `mode` escalates and never de-escalates while a row is pending: a 'force' request --- landing on a pending 'gated' one upgrades it, because a user who just connected a --- tracker or pressed "Sync now" must not have their explicit request downgraded by a --- passing window focus. The reverse is silently ignored by the producer's UPSERT. --- --- COMPLETION IS REPORTED IN PLACE, NOT DELETED --- The daemon stamps `completed_at` plus `error` / `synced_count` rather than removing --- the row, so "Sync now" can show a real outcome without the tray needing to hold the --- credential or shell out. A serviced row is retained until the next request replaces --- it, which also gives `meridian health` a cheap, content-free view of the last sync --- attempt. - -CREATE TABLE IF NOT EXISTS pm_sync_requests ( - -- '*' = all configured providers. A specific provider name scopes the request. - provider TEXT NOT NULL PRIMARY KEY, - -- 'gated' - honour the per-provider staleness window (the cheap common case). - -- 'force' - bypass it; the user explicitly asked (connect, "Sync now", CLI). - mode TEXT NOT NULL DEFAULT 'gated', - -- Free-text producer tag for tracing only (e.g. 'dashboard_open', 'token_connected'). - -- Never a user-content value: this is read back into logs. - reason TEXT NOT NULL DEFAULT '', - requested_at TEXT NOT NULL, - -- Set when the daemon starts servicing, so a request in flight is distinguishable - -- from one still waiting. Cleared on the next request. - claimed_at TEXT, - -- Set when the daemon finishes, success or failure. NULL while pending/in-flight. - completed_at TEXT, - -- NULL on success. The failure detail otherwise, for the tray to surface. - error TEXT, - -- Tasks refreshed on the last completed pass, for "Sync now" feedback. - synced_count INTEGER -); - --- No secondary index on purpose. Every hot query (`claim`, `complete`, `outcome`, --- `request`) filters on `provider`, which is the PRIMARY KEY, so an index on --- (completed_at, claimed_at) would never be chosen. The only query that does not --- name a provider is `reset_stale_claims`, which runs once per daemon boot over a --- table holding one row per provider - a scan there is free. diff --git a/src/plan_tasks/create.rs b/src/plan_tasks/create.rs index 2b8867e27..16a62e1b7 100644 --- a/src/plan_tasks/create.rs +++ b/src/plan_tasks/create.rs @@ -30,22 +30,13 @@ //! - [`meridian_core::plan`] — `apply_plan_action("add")`, which puts the key in today. use anyhow::{Context, Result}; -use meridian_core::pm_sync_requests::SyncMode; use meridian_core::task_create::{self, NewTask}; use serde::Serialize; use sqlx::SqlitePool; -use std::time::Duration; use tracing::field::Empty; use tracing::Instrument; use crate::config::Config; -use crate::intelligence::sync_delegate::Delegation; - -/// How long to wait for the post-create sync before falling through to the shadow row. -/// Must stay comfortably inside the tray's 90 s `WRITE_TIMEOUT` for `plan-task-create`, -/// since a blown tray timeout looks to the user like a failed create even though the -/// ticket was filed. -const POST_CREATE_SYNC_BUDGET: Duration = Duration::from_secs(60); /// Where a new task should live. #[derive(Debug, Clone, PartialEq, Eq)] @@ -186,30 +177,9 @@ async fn create_on_tracker( tracing::Span::current().record("synced", true); // Pull the authoritative row in immediately rather than waiting out the 5-minute - // sync gate. Best-effort: a failed sync is not a failed create - it just means the - // `task_exists` check below falls through to the shadow row. - // - // Delegated to the daemon (see `intelligence::sync_delegate`) because this is a - // short-lived CLI process and the rotating Jira OAuth token has exactly one safe - // writer. Unlike the edit/done paths this WAITS for the outcome: the very next line - // reads `pm_tasks`, so returning before the sync landed would shadow every created - // ticket. The budget sits inside the tray's 90 s `WRITE_TIMEOUT` for this command. - match crate::intelligence::sync_delegate::sync_and_wait( - pool, - config, - SyncMode::Force, - "plan-task-create", - POST_CREATE_SYNC_BUDGET, - ) - .await - { - Delegation::Synced { .. } => {} - Delegation::Failed { error } => { - tracing::warn!(%error, "plan_task: post-create sync failed - will shadow the row"); - } - Delegation::Pending => { - tracing::warn!("plan_task: post-create sync still running - will shadow the row"); - } + // sync gate. Best-effort: a failed sync is not a failed create. + if let Err(e) = crate::intelligence::run_pm_force_sync(pool, config).await { + tracing::warn!(error = %e, "plan_task: post-create sync failed - will shadow the row"); } if task_create::task_exists(pool, &key).await? { diff --git a/src/plan_tasks/done.rs b/src/plan_tasks/done.rs index e0a5643d4..53dc4496a 100644 --- a/src/plan_tasks/done.rs +++ b/src/plan_tasks/done.rs @@ -29,7 +29,6 @@ use tracing::field::Empty; use tracing::Instrument; use crate::config::Config; -use crate::intelligence::sync_delegate::Delegation; use crate::intelligence::ticket_update::{self, ApplyStatus}; use crate::plan_tasks::edit::EditResult; @@ -118,20 +117,8 @@ async fn set_done_on_tracker( // Best-effort — the tracker has already accepted the transition, so a sync hiccup // must not read as a failed toggle. - // - // Delegated to the daemon (`intelligence::sync_delegate`), which is the only process - // that may spend the rotating Jira OAuth token. Nothing below reads `pm_tasks`, so - // this does not wait for the outcome - the request row is written and the daemon - // picks it up within ~2 s, well before the user's next board read. - match crate::intelligence::sync_delegate::sync_after_write(pool, config, "plan-task-done").await - { - Delegation::Synced { .. } => {} - Delegation::Failed { error } => { - tracing::warn!(%error, "plan_task: post-toggle sync failed - the change still landed"); - } - Delegation::Pending => { - tracing::warn!("plan_task: post-toggle sync still running - the change still landed"); - } + if let Err(e) = crate::intelligence::run_pm_force_sync(pool, config).await { + tracing::warn!(error = %e, "plan_task: post-toggle sync failed - the change still landed"); } tracing::Span::current().record("status", "applied"); tracing::info!(field, "plan_task: tracker task status set"); diff --git a/src/plan_tasks/edit.rs b/src/plan_tasks/edit.rs index df4523497..7b244755e 100644 --- a/src/plan_tasks/edit.rs +++ b/src/plan_tasks/edit.rs @@ -32,7 +32,6 @@ use tracing::field::Empty; use tracing::Instrument; use crate::config::Config; -use crate::intelligence::sync_delegate::Delegation; use crate::intelligence::ticket_update::{self, ApplyStatus}; /// The outcome of an edit, serialized to the CLI's one JSON line. @@ -158,20 +157,8 @@ async fn edit_on_tracker( // Reflect the applied write back into our mirror (the `ticket-update` CLI // does exactly this after an Applied write). Best-effort — the tracker has // already accepted it, so a sync hiccup must not read as a failed edit. - // - // Delegated to the daemon (`intelligence::sync_delegate`), the only process that - // may spend the rotating Jira OAuth token. Nothing below reads `pm_tasks`, so the - // outcome is not awaited. - match crate::intelligence::sync_delegate::sync_after_write(pool, config, "plan-task-edit") - .await - { - Delegation::Synced { .. } => {} - Delegation::Failed { error } => { - tracing::warn!(%error, "plan_task: post-edit sync failed - the edit still landed"); - } - Delegation::Pending => { - tracing::warn!("plan_task: post-edit sync still running - the edit still landed"); - } + if let Err(e) = crate::intelligence::run_pm_force_sync(pool, config).await { + tracing::warn!(error = %e, "plan_task: post-edit sync failed - the edit still landed"); } } tracing::Span::current().record("status", "applied"); diff --git a/src/pm_worklog/auto_generate/sweep.rs b/src/pm_worklog/auto_generate.rs similarity index 53% rename from src/pm_worklog/auto_generate/sweep.rs rename to src/pm_worklog/auto_generate.rs index 9586e0431..8e693e819 100644 --- a/src/pm_worklog/auto_generate/sweep.rs +++ b/src/pm_worklog/auto_generate.rs @@ -1,11 +1,96 @@ //ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity -//! The shared sweep body [`super::maybe_auto_generate`] (clock-gated) and -//! [`super::generate_now`] (on demand) both call: draft a worklog for every -//! qualifying, not-yet-drafted day-task. Split out of `auto_generate.rs` when that -//! file passed the repo's 500-line cap — see `auto_generate/mod.rs`'s module doc -//! for the full feature description; this file is the mechanical loop only. +//! Auto-generate worklog DRAFTS, once a day, at a user-chosen local clock time — +//! the opt-in behind `settings.worklog_auto_generate_time`. +//! +//! This runs the exact same action as the day-task detail panel's "Generate +//! worklog" button (`pm_worklog::generate`), automatically, for any task past the +//! fixed [`QUALIFYING_MINUTES`] threshold that doesn't have a draft yet. It NEVER +//! approves or posts — approving is always a deliberate human click +//! (`pm_worklog::approve`, the panel's "Approve & post"). Auto-generate only saves +//! the user the click that starts the draft; the tracker is never touched without +//! them. +//! +//! Off by default: the field is `None` until the user picks a time from the +//! Timeline nudge or Settings → Worklogs, so nothing here ever runs unattended for +//! someone who hasn't said yes. +//! +//! # A TRACKER IS NOT REQUIRED — and used to be, wrongly +//! Both entry points opened with `if config.pm_providers.is_empty() { return }`, +//! on the premise that "there is nothing to match against, so the sweep would only +//! fail every call". That premise is false, and stopped being true the moment +//! personal tasks became first-class: [`crate::pm_worklog::generate`] matches a +//! PERSONAL (`local`) day-task exactly like a real ticket and, on approve, posts to +//! that task's own row (`post_to_local_task`) — no tracker anywhere in the path. +//! Only the PROPOSE branch needs a configured provider, and that branch failing is +//! already a per-task `warn!` the loop walks past. +//! +//! So the gate silently disabled the whole feature for every tracker-less user — +//! the cohort Meridian now leads with — and Settings mirrored it with a dead-end +//! card offering nothing. Removed at both ends. Keep it that way: the correct floor +//! is "does this task have qualifying minutes and no draft yet", which is what the +//! sweep already checks, not "does this user have Jira". +//! +//! # Once a day, from the chosen time onward — not continuously, and self-healing +//! [`maybe_auto_generate`] is called every clock-aligned wake (HH:03, same as the +//! rest of the worklog pipeline), but only actually scans once the current local +//! hour has REACHED the chosen time's hour — so a task crossing the threshold at +//! 2pm with a 6pm setting waits for 6pm, not the next tick after 2pm. +//! +//! Deliberately `>=`, not `==`: if the Mac is asleep, off, or the app isn't +//! running at exactly 9pm, there is no tick at 9pm to miss — the daemon simply +//! catches up on its next wake that same day (10pm, or whenever the machine comes +//! back), because every tick from the chosen hour through local midnight re-runs +//! the same scan. This is safe to repeat because already-drafted tasks are +//! skipped (see below) — a caught-up run only ever drafts what genuinely wasn't +//! drafted yet, it never redoes work. The one thing this does NOT do is reach +//! back across local midnight: if the whole rest of the day was slept through, +//! that day's catch-up is skipped, matching this pipeline's standing "no +//! backfill past today" policy — the manual "Generate worklog" button in the +//! panel always still works for any past day. +//! +//! # Fires once per task, not on every qualifying tick +//! Once a task has ANY draft (auto- or manually generated), this leaves it alone +//! for good — it does not keep re-drafting an already-drafted task on a later +//! tick, whether that's later the same evening (the catch-up case above) or a +//! future day. If the user keeps working the task past the auto-draft, getting +//! an up-to-date version is a manual "Regenerate" click in the panel (which +//! shows when the draft was last generated, via `DayTaskWorklogDraft:: +//! updated_at`) — auto-generate hands off to that rather than looping forever. +//! +//! # One task at a time +//! Tasks are processed in a plain sequential loop, not fanned out — each `generate` +//! call is a real LLM request, and running them one after another (rather than +//! concurrently) keeps this predictable and easy to reason about from the trace. +//! +//! # Observability +//! The whole run is one `worklog.auto_generate.run` span (see +//! [`maybe_auto_generate`]) carrying the gate outcome and per-run counts as +//! attributes — `gate` names exactly why a run did or didn't scan (`disabled`, +//! `before_chosen_hour`, `malformed_time`, or `ran`), so "why didn't this fire +//! tonight" is answerable from the trace alone, not by reading code. Each task +//! that actually gets drafted (or fails to) gets its own `info!`/`warn!` — real, +//! individually actionable events — but a task skipped for already having a +//! draft or not yet qualifying is silent (folded into the summary counts only), +//! so a quiet evening with nothing to do doesn't spam the log with one line per +//! task. +//! +//! # Who calls this +//! [`crate::worklog_pipeline::run_loop`], every clock-aligned wake (HH:03), right +//! after the hourly activity-report pass. +//! +//! # Related +//! - [`crate::pm_worklog::generate`] — the exact function the manual button calls; +//! this module never calls [`crate::pm_worklog::approve`]. +//! - [`meridian_core::day_tasks::get_day_tasks`] — the read model this scans; +//! `DayTask::minutes` is the deterministic measured total. +//! - [`meridian_core::day_task_worklogs::get_day_task_worklog`] — the "has this +//! task already been drafted" check that makes auto-generate fire exactly once +//! per task. +use chrono::{Local, Timelike}; use sqlx::SqlitePool; +use tracing::field::Empty; +use tracing::Instrument; use crate::config::Config; use crate::pm_worklog; @@ -13,14 +98,101 @@ use crate::pm_worklog; /// Fixed qualifying threshold — a day-task needs more than this many tracked /// minutes to be auto-drafted. Not user-configurable: only WHEN Meridian checks /// (`worklog_auto_generate_time`) is a choice; WHICH tasks qualify is not. -pub(super) const QUALIFYING_MINUTES: i64 = 30; +const QUALIFYING_MINUTES: i64 = 30; + +/// Parse "HH:MM" into `(hour, minute)`. `None` for anything malformed — the +/// settings write path (`update_settings`) already rejects a bad value, so this +/// only has to be defensive against a hand-edited file. +fn parse_hh_mm(s: &str) -> Option<(u32, u32)> { + let (h, m) = s.split_once(':')?; + let (h, m) = (h.parse::().ok()?, m.parse::().ok()?); + (h < 24 && m < 60).then_some((h, m)) +} + +/// Once the current local hour has reached the user's chosen hour (today), scan +/// `day_local`'s day-tasks and auto-generate a worklog DRAFT (never approve, +/// never post) for any past [`QUALIFYING_MINUTES`] that doesn't have a draft yet. +/// A no-op when `worklog_auto_generate_time` is unset, or before that hour. Safe +/// to call again later the same day (e.g. the machine was asleep at the chosen +/// time) — see the module docs. Tasks are processed one at a time, in order — +/// never concurrently. +#[tracing::instrument(skip(pool, config))] +pub async fn maybe_auto_generate(pool: &SqlitePool, config: &Config, day_local: &str) { + let span = tracing::info_span!( + "worklog.auto_generate.run", + day = day_local, + chosen_time = Empty, + gate = Empty, + tasks_total = Empty, + tasks_qualifying = Empty, + tasks_already_drafted = Empty, + tasks_drafted = Empty, + tasks_failed = Empty, + ); + run(pool, config, day_local).instrument(span).await +} + +async fn run(pool: &SqlitePool, config: &Config, day_local: &str) { + let current_span = tracing::Span::current(); + + let settings = meridian_core::settings::load_runtime_settings(); + let Some(chosen_time) = settings.worklog_auto_generate_time else { + current_span.record("gate", "disabled"); + return; + }; + current_span.record("chosen_time", chosen_time.as_str()); + + let Some((chosen_hour, _)) = parse_hh_mm(&chosen_time) else { + current_span.record("gate", "malformed_time"); + tracing::warn!( + chosen_time, + "worklog: auto-generate time is malformed — skipping" + ); + return; + }; + if Local::now().hour() < chosen_hour { + current_span.record("gate", "before_chosen_hour"); + return; + } + current_span.record("gate", "ran"); + + draft_qualifying_tasks(pool, config, day_local, ¤t_span).await; +} + +/// The "Generate now" path — the same day-task draft sweep as [`maybe_auto_generate`], +/// but WITHOUT the time-of-day gate, run because the user asked for it on the spot +/// (the daily-summary screen's "Generate now" button) rather than because the clock +/// reached their chosen time. Everything else is identical: drafts only, never +/// approves/posts, and skips any task that already has a draft, so it is safe to run +/// alongside or before the scheduled pass. Independent of `worklog_auto_generate_time`: +/// a user who never set a time can still generate on demand. +#[tracing::instrument(skip(pool, config))] +pub async fn generate_now(pool: &SqlitePool, config: &Config, day_local: &str) { + let span = tracing::info_span!( + "worklog.generate_now.run", + day = day_local, + gate = Empty, + tasks_total = Empty, + tasks_qualifying = Empty, + tasks_already_drafted = Empty, + tasks_drafted = Empty, + tasks_failed = Empty, + ); + async { + let current_span = tracing::Span::current(); + current_span.record("gate", "ran"); + draft_qualifying_tasks(pool, config, day_local, ¤t_span).await; + } + .instrument(span) + .await +} /// What one sweep did. Returned rather than only recorded onto the span so the /// behaviour is assertable: "did this run at all" and "did it early-return" are /// otherwise indistinguishable from outside, which is exactly how a tracker gate /// silently disabled the whole feature for solo users once already. #[derive(Debug, Default, PartialEq, Eq)] -pub(super) struct SweepCounts { +struct SweepCounts { total: usize, qualifying: u32, already_drafted: u32, @@ -29,36 +201,19 @@ pub(super) struct SweepCounts { } /// Draft a worklog for every qualifying, not-yet-drafted day-task of `day_local`, -/// recording per-run counts onto `span`. The shared body of -/// [`super::maybe_auto_generate`] (gated on the clock) and [`super::generate_now`] -/// (on demand); it assumes its caller has already decided a run is warranted. +/// recording per-run counts onto `span` and returning them. The shared body of +/// [`maybe_auto_generate`] (gated on the clock) and [`generate_now`] (on demand); it +/// assumes its caller has already decided a run is warranted. /// /// Deliberately NOT gated on a connected tracker - a personal day-task is matched and /// drafted like any ticket, and only the propose branch needs one (it already fails /// per-task). See [`neither_sweep_is_gated_on_a_connected_tracker`]. -pub(super) async fn draft_qualifying_tasks( +async fn draft_qualifying_tasks( pool: &SqlitePool, config: &Config, day_local: &str, span: &tracing::Span, ) -> SweepCounts { - // Matching a day-task against `pm_tasks` wants current ticket state, and there is - // no background poller keeping it fresh anymore — so this sweep asks for it. - // - // UNATTENDED on purpose. This runs on a clock (HH:03), which makes it the one - // remaining path that could fire a Jira OAuth refresh with nobody at the machine - // — the exact shape that permanently killed a production user's grant when a - // refresh POST straddled a 28-minute suspend. `run_pm_sync_unattended` will use a - // valid access token but never mint one, so an expired token simply defers to the - // next attended request instead of betting the grant. Best-effort either way: a - // sync failure must not block drafting against whatever is cached. - if let Err(e) = crate::intelligence::run_pm_sync_unattended(pool, config).await { - tracing::warn!( - day = day_local, error = %e, - "worklog: auto-generate pm sync failed — matching against cached tasks" - ); - } - let tasks = match meridian_core::day_tasks::get_day_tasks(pool, day_local).await { Ok(resp) => resp.tasks, Err(e) => { @@ -150,7 +305,7 @@ pub(super) async fn draft_qualifying_tasks( #[cfg(test)] mod tests { - use super::{draft_qualifying_tasks, SweepCounts, QUALIFYING_MINUTES}; + use super::{draft_qualifying_tasks, parse_hh_mm, SweepCounts, QUALIFYING_MINUTES}; use crate::config::Config; use sqlx::sqlite::SqlitePoolOptions; use sqlx::SqlitePool; @@ -294,6 +449,25 @@ mod tests { assert_eq!(counts, SweepCounts::default()); } + #[test] + fn parses_valid_times() { + assert_eq!(parse_hh_mm("00:00"), Some((0, 0))); + assert_eq!(parse_hh_mm("18:00"), Some((18, 0))); + assert_eq!(parse_hh_mm("23:59"), Some((23, 59))); + assert_eq!(parse_hh_mm("09:05"), Some((9, 5))); + } + + #[test] + fn rejects_out_of_range_or_malformed_values() { + assert_eq!(parse_hh_mm("24:00"), None); + assert_eq!(parse_hh_mm("18:60"), None); + assert_eq!(parse_hh_mm("18"), None); + assert_eq!(parse_hh_mm("18:00:00"), None); + assert_eq!(parse_hh_mm(""), None); + assert_eq!(parse_hh_mm("not-a-time"), None); + assert_eq!(parse_hh_mm("ab:cd"), None); + } + /// Neither entry point may re-acquire a "do they have a tracker" precondition. /// /// Source-level because the gate was a bare early return with no observable @@ -303,10 +477,7 @@ mod tests { /// ticket); only the propose branch needs one, and it already fails per-task. #[test] fn neither_sweep_is_gated_on_a_connected_tracker() { - // Both files: `maybe_auto_generate`/`generate_now` (the entry points) live in - // `mod.rs`, the actual per-task loop lives here — a gate could reappear in - // either. - let src = concat!(include_str!("mod.rs"), "\n", include_str!("sweep.rs")); + let src = include_str!("auto_generate.rs"); // Comment lines dropped, not just their markers - the module doc above // quotes the removed gate verbatim to explain why it went. let code: String = src diff --git a/src/pm_worklog/auto_generate/mod.rs b/src/pm_worklog/auto_generate/mod.rs deleted file mode 100644 index a72ebe467..000000000 --- a/src/pm_worklog/auto_generate/mod.rs +++ /dev/null @@ -1,216 +0,0 @@ -//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity -//! Auto-generate worklog DRAFTS, once a day, at a user-chosen local clock time — -//! the opt-in behind `settings.worklog_auto_generate_time`. -//! -//! This runs the exact same action as the day-task detail panel's "Generate -//! worklog" button (`pm_worklog::generate`), automatically, for any task past the -//! fixed [`sweep::QUALIFYING_MINUTES`] threshold that doesn't have a draft yet. It -//! NEVER approves or posts — approving is always a deliberate human click -//! (`pm_worklog::approve`, the panel's "Approve & post"). Auto-generate only saves -//! the user the click that starts the draft; the tracker is never touched without -//! them. -//! -//! Off by default: the field is `None` until the user picks a time from the -//! Timeline nudge or Settings → Worklogs, so nothing here ever runs unattended for -//! someone who hasn't said yes. -//! -//! # A TRACKER IS NOT REQUIRED — and used to be, wrongly -//! Both entry points opened with `if config.pm_providers.is_empty() { return }`, -//! on the premise that "there is nothing to match against, so the sweep would only -//! fail every call". That premise is false, and stopped being true the moment -//! personal tasks became first-class: [`crate::pm_worklog::generate`] matches a -//! PERSONAL (`local`) day-task exactly like a real ticket and, on approve, posts to -//! that task's own row (`post_to_local_task`) — no tracker anywhere in the path. -//! Only the PROPOSE branch needs a configured provider, and that branch failing is -//! already a per-task `warn!` the loop walks past. -//! -//! So the gate silently disabled the whole feature for every tracker-less user — -//! the cohort Meridian now leads with — and Settings mirrored it with a dead-end -//! card offering nothing. Removed at both ends. Keep it that way: the correct floor -//! is "does this task have qualifying minutes and no draft yet", which is what the -//! sweep already checks, not "does this user have Jira". -//! -//! # Once a day, from the chosen time onward — not continuously, and self-healing -//! [`maybe_auto_generate`] is called every clock-aligned wake (HH:03, same as the -//! rest of the worklog pipeline), but only actually scans once the current local -//! hour has REACHED the chosen time's hour — so a task crossing the threshold at -//! 2pm with a 6pm setting waits for 6pm, not the next tick after 2pm. -//! -//! Deliberately `>=`, not `==`: if the Mac is asleep, off, or the app isn't -//! running at exactly 9pm, there is no tick at 9pm to miss — the daemon simply -//! catches up on its next wake that same day (10pm, or whenever the machine comes -//! back), because every tick from the chosen hour through local midnight re-runs -//! the same scan. This is safe to repeat because already-drafted tasks are -//! skipped (see below) — a caught-up run only ever drafts what genuinely wasn't -//! drafted yet, it never redoes work. The one thing this does NOT do is reach -//! back across local midnight: if the whole rest of the day was slept through, -//! that day's catch-up is skipped, matching this pipeline's standing "no -//! backfill past today" policy — the manual "Generate worklog" button in the -//! panel always still works for any past day. -//! -//! # Fires once per task, not on every qualifying tick -//! Once a task has ANY draft (auto- or manually generated), this leaves it alone -//! for good — it does not keep re-drafting an already-drafted task on a later -//! tick, whether that's later the same evening (the catch-up case above) or a -//! future day. If the user keeps working the task past the auto-draft, getting -//! an up-to-date version is a manual "Regenerate" click in the panel (which -//! shows when the draft was last generated, via `DayTaskWorklogDraft:: -//! updated_at`) — auto-generate hands off to that rather than looping forever. -//! -//! # One task at a time -//! Tasks are processed in a plain sequential loop, not fanned out — each `generate` -//! call is a real LLM request, and running them one after another (rather than -//! concurrently) keeps this predictable and easy to reason about from the trace. -//! -//! # Board freshness -//! [`sweep::draft_qualifying_tasks`] syncs the PM board (gated, ~5 min per provider) -//! before matching — there is no background poller keeping `pm_tasks` fresh -//! anymore, so this sweep is one of the few places that has to ask for it itself. -//! -//! # Observability -//! The whole run is one `worklog.auto_generate.run` span (see -//! [`maybe_auto_generate`]) carrying the gate outcome and per-run counts as -//! attributes — `gate` names exactly why a run did or didn't scan (`disabled`, -//! `before_chosen_hour`, `malformed_time`, or `ran`), so "why didn't this fire -//! tonight" is answerable from the trace alone, not by reading code. Each task -//! that actually gets drafted (or fails to) gets its own `info!`/`warn!` — real, -//! individually actionable events — but a task skipped for already having a -//! draft or not yet qualifying is silent (folded into the summary counts only), -//! so a quiet evening with nothing to do doesn't spam the log with one line per -//! task. -//! -//! # Who calls this -//! [`crate::worklog_pipeline::run_loop`], every clock-aligned wake (HH:03), right -//! after the hourly activity-report pass. -//! -//! # Related -//! - [`crate::pm_worklog::generate`] — the exact function the manual button calls; -//! this module never calls [`crate::pm_worklog::approve`]. -//! - [`meridian_core::day_tasks::get_day_tasks`] — the read model this scans; -//! `DayTask::minutes` is the deterministic measured total. -//! - [`meridian_core::day_task_worklogs::get_day_task_worklog`] — the "has this -//! task already been drafted" check that makes auto-generate fire exactly once -//! per task. -//! - [`sweep`] — the mechanical per-task loop, split out when this file passed the -//! 500-line cap. - -mod sweep; - -use chrono::{Local, Timelike}; -use sqlx::SqlitePool; -use tracing::field::Empty; -use tracing::Instrument; - -use crate::config::Config; -use sweep::draft_qualifying_tasks; - -/// Parse "HH:MM" into `(hour, minute)`. `None` for anything malformed — the -/// settings write path (`update_settings`) already rejects a bad value, so this -/// only has to be defensive against a hand-edited file. -fn parse_hh_mm(s: &str) -> Option<(u32, u32)> { - let (h, m) = s.split_once(':')?; - let (h, m) = (h.parse::().ok()?, m.parse::().ok()?); - (h < 24 && m < 60).then_some((h, m)) -} - -/// Once the current local hour has reached the user's chosen hour (today), scan -/// `day_local`'s day-tasks and auto-generate a worklog DRAFT (never approve, -/// never post) for any past [`sweep::QUALIFYING_MINUTES`] that doesn't have a draft -/// yet. A no-op when `worklog_auto_generate_time` is unset, or before that hour. -/// Safe to call again later the same day (e.g. the machine was asleep at the -/// chosen time) — see the module docs. Tasks are processed one at a time, in -/// order — never concurrently. -#[tracing::instrument(skip(pool, config))] -pub async fn maybe_auto_generate(pool: &SqlitePool, config: &Config, day_local: &str) { - let span = tracing::info_span!( - "worklog.auto_generate.run", - day = day_local, - chosen_time = Empty, - gate = Empty, - tasks_total = Empty, - tasks_qualifying = Empty, - tasks_already_drafted = Empty, - tasks_drafted = Empty, - tasks_failed = Empty, - ); - run(pool, config, day_local).instrument(span).await -} - -async fn run(pool: &SqlitePool, config: &Config, day_local: &str) { - let current_span = tracing::Span::current(); - - let settings = meridian_core::settings::load_runtime_settings(); - let Some(chosen_time) = settings.worklog_auto_generate_time else { - current_span.record("gate", "disabled"); - return; - }; - current_span.record("chosen_time", chosen_time.as_str()); - - let Some((chosen_hour, _)) = parse_hh_mm(&chosen_time) else { - current_span.record("gate", "malformed_time"); - tracing::warn!( - chosen_time, - "worklog: auto-generate time is malformed — skipping" - ); - return; - }; - if Local::now().hour() < chosen_hour { - current_span.record("gate", "before_chosen_hour"); - return; - } - current_span.record("gate", "ran"); - - draft_qualifying_tasks(pool, config, day_local, ¤t_span).await; -} - -/// The "Generate now" path — the same day-task draft sweep as [`maybe_auto_generate`], -/// but WITHOUT the time-of-day gate, run because the user asked for it on the spot -/// (the daily-summary screen's "Generate now" button) rather than because the clock -/// reached their chosen time. Everything else is identical: drafts only, never -/// approves/posts, and skips any task that already has a draft, so it is safe to run -/// alongside or before the scheduled pass. Independent of `worklog_auto_generate_time`: -/// a user who never set a time can still generate on demand. -#[tracing::instrument(skip(pool, config))] -pub async fn generate_now(pool: &SqlitePool, config: &Config, day_local: &str) { - let span = tracing::info_span!( - "worklog.generate_now.run", - day = day_local, - gate = Empty, - tasks_total = Empty, - tasks_qualifying = Empty, - tasks_already_drafted = Empty, - tasks_drafted = Empty, - tasks_failed = Empty, - ); - async { - let current_span = tracing::Span::current(); - current_span.record("gate", "ran"); - draft_qualifying_tasks(pool, config, day_local, ¤t_span).await; - } - .instrument(span) - .await -} - -#[cfg(test)] -mod tests { - use super::parse_hh_mm; - - #[test] - fn parses_valid_times() { - assert_eq!(parse_hh_mm("00:00"), Some((0, 0))); - assert_eq!(parse_hh_mm("18:00"), Some((18, 0))); - assert_eq!(parse_hh_mm("23:59"), Some((23, 59))); - assert_eq!(parse_hh_mm("09:05"), Some((9, 5))); - } - - #[test] - fn rejects_out_of_range_or_malformed_values() { - assert_eq!(parse_hh_mm("24:00"), None); - assert_eq!(parse_hh_mm("18:60"), None); - assert_eq!(parse_hh_mm("18"), None); - assert_eq!(parse_hh_mm("18:00:00"), None); - assert_eq!(parse_hh_mm(""), None); - assert_eq!(parse_hh_mm("not-a-time"), None); - assert_eq!(parse_hh_mm("ab:cd"), None); - } -} diff --git a/tray/src-tauri/src/commands/integrations.rs b/tray/src-tauri/src/commands/integrations.rs index 2e6e1d502..5dbe8b53a 100644 --- a/tray/src-tauri/src/commands/integrations.rs +++ b/tray/src-tauri/src/commands/integrations.rs @@ -681,11 +681,6 @@ pub async fn save_integration_token( tracing::debug!("daemon reload after token save (non-fatal — will pick up on next start)"); } - // First-time (or credential-change) connect — force a sync so the board - // populates immediately rather than waiting for the next on-demand trigger. - // There's no stale cache to protect here, so gating buys nothing. - crate::commands::tasks::trigger_background_pm_force_sync(db_pool.get(), "token_connected"); - Ok(serde_json::json!({ "ok": true, "reloaded": reloaded })) } @@ -1203,7 +1198,7 @@ pub async fn start_oauth( db_pool: State<'_, crate::db_pool::DbPool>, ) -> Result { match body.provider.as_str() { - "jira" | "trello" => start_oauth_in_process(body.provider, db_pool.get()), + "jira" | "trello" => start_oauth_in_process(body.provider), "github" => start_oauth_github_device(body.provider, db_pool.inner().clone()).await, other => Err(format!("Unknown provider: {other}")), } @@ -1256,10 +1251,7 @@ pub async fn cancel_oauth(body: CancelOAuthBody) -> Result<(), String> { /// functions — avoiding `std::env::set_var` on a Tokio worker thread (POSIX /// setenv is not thread-safe under concurrent env reads). A per-provider /// [`AtomicBool`] prevents two flows from racing to bind the same loopback port. -fn start_oauth_in_process( - provider: String, - db: Option, -) -> Result { +fn start_oauth_in_process(provider: String) -> Result { // Resolve credentials from .env WITHOUT mutating process env. let mode = crate::install::detect_install_mode(); let dot_env = mode.env_path().map(parse_env).unwrap_or_default(); @@ -1351,12 +1343,7 @@ fn start_oauth_in_process( // Always clear the in-flight flag before returning, regardless of outcome. in_flight.store(false, Ordering::SeqCst); match result { - Ok(()) => { - tracing::info!(provider = %task_provider, "in-process OAuth login succeeded"); - // First-time connect — force a sync so the board populates - // immediately rather than waiting for the next on-demand trigger. - crate::commands::tasks::trigger_background_pm_force_sync(db, "oauth_connected"); - } + Ok(()) => tracing::info!(provider = %task_provider, "in-process OAuth login succeeded"), Err(e) => { let msg = format!("{e:#}"); tracing::warn!(provider = %task_provider, error = %msg, "in-process OAuth login failed"); @@ -1470,19 +1457,10 @@ async fn start_oauth_github_device( return; } tracing::info!("GitHub device-flow login succeeded"); - // Grab the DB handle BEFORE `db_pool` is moved into the reload below, - // so the sync request can still be written afterwards. - let sync_db = db_pool.get(); // Best-effort reload so the token takes effect now, not next restart. if let Err(e) = crate::commands::daemon::reload_daemon_with(db_pool).await { tracing::debug!(error = %e, "daemon reload after GitHub connect (non-fatal)"); } - // First-time connect — force a sync so the board populates - // immediately rather than waiting for the next on-demand trigger. - crate::commands::tasks::trigger_background_pm_force_sync( - sync_db, - "oauth_connected", - ); } Err(e) => { let msg = format!("{e:#}"); diff --git a/tray/src-tauri/src/commands/tasks.rs b/tray/src-tauri/src/commands/tasks.rs index f626c7bae..882eb515e 100644 --- a/tray/src-tauri/src/commands/tasks.rs +++ b/tray/src-tauri/src/commands/tasks.rs @@ -1,72 +1,29 @@ //ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity -//! Task-board action commands — the ported `/api/tasks/sync` POST, plus the -//! on-demand sync triggers every other tray command fires into. +//! Task-board action commands — the ported `/api/tasks/sync` POST. //! -//! There is no background poller anymore: syncing `pm_tasks` from the connected -//! tracker happens only at genuine on-demand moments — the manual "Sync now" -//! button ([`sync_tasks`], force), connecting a tracker -//! ([`trigger_background_pm_force_sync`], force, fire-and-forget), plan mount and -//! the match-to-ticket picker ([`request_gated_sync_tasks`], gated, waits for -//! result). -//! -//! # The tray does not sync, it ASKS -//! -//! Every function here writes a row to `pm_sync_requests` and the **daemon** does the -//! work. The tray never holds a tracker credential, and it no longer spawns a -//! `meridian` process to borrow one either. -//! -//! That is not tidiness, it is the fix for a production incident. An Atlassian OAuth -//! refresh token is single-use and rotating: the old token dies the instant the new -//! one is issued, so a lost response leaves the grant recoverable only inside a -//! 10-minute window and permanently dead after it. The credential therefore has -//! exactly one safe writer — and it used to have several (daemon, tray in-process, -//! plus a fresh CLI process per trigger). The advisory file lock meant to serialise -//! them could not: its 10 s timeout is shorter than the ~26 s a refresh can take, and -//! on timeout the code proceeded WITHOUT the lock. Only Atlassian's grace window kept -//! that from corrupting state. -//! -//! Requesting instead of doing also removed a per-trigger process spawn: opening the -//! dashboard used to start a whole `meridian pm-sync` process — process init, DB -//! open, config load — which then usually discovered the staleness gate and did -//! nothing. It is now one coalescing UPSERT. -//! -//! (The per-task *read*, `get_tasks`, stays in [`crate::commands::dashboard`], and -//! deliberately never triggers a sync itself — it's polled every 30-60s while a panel -//! is mounted, and wiring a sync to that would silently rebuild the background-timer -//! problem this replaces.) +//! Re-syncs the PM board: spawns `meridian tasks-sync`, which pulls the latest +//! tickets from the connected tracker into `pm_tasks`. Tracker auth lives in the +//! daemon, so — like every tracker write — this shells out to the CLI rather than +//! talking to the provider directly; it's a process spawn, so it lives tray-side, +//! not in meridian-core. (The per-task *read*, `get_tasks`, stays in +//! [`crate::commands::dashboard`].) //! //! # Who calls this -//! [`sync_tasks`] and [`request_gated_sync_tasks`] are registered in `lib.rs`'s -//! `invoke_handler!`. `sync_tasks` (force) backs every "Sync now" — the Tasks panel, -//! the planner's Refresh chip, and the per-provider button in Settings — via -//! `ui/lib/taskSync.ts::syncTasks`. `request_gated_sync_tasks` backs -//! `taskSync.ts::requestGatedTaskSync`, called by `PlanView` on mount and -//! `WorklogTicketPicker` on open. -//! -//! [`trigger_background_pm_force_sync`] is a plain Rust fn (not a Tauri command), -//! called from `commands::integrations`'s connect-success paths. -//! -//! Nothing calls a gated sync from a window opener any more: `open_dashboard` and -//! `tray::open_native_dashboard` used to, and no longer do. +//! Registered in `lib.rs`'s `invoke_handler!`; consumed by `TasksView.tsx`'s Sync +//! button via `ui/lib/bridge.ts::mutate` (success → re-fetch; error → inline msg). //! //! # Related -//! - [`meridian_core::pm_sync_requests`] — the request/claim/complete API. -//! - `src/intelligence/sync_requests.rs` — the daemon-side consumer. -//! - `src/intelligence/sync_delegate.rs` — the producer side for the `meridian` CLI -//! subcommands the tray spawns (`tasks-sync`, `pm-sync`, `plan-task-*`, -//! `ticket-update`, `ticket-set-status`, `worklog-generate`), which delegate to the -//! same outbox when a daemon is running, for the same reason. +//! - [`crate::install::meridian_bin`] — the shared native-first binary resolver. +//! - [`crate::commands::parents`] — the other read-side `meridian` CLI shell-out. -use meridian_core::pm_sync_requests::{self, SyncMode, ALL_PROVIDERS}; -use meridian_core::SqlitePool; +use meridian_core::proc_ext::NoWindow; use serde::Serialize; use std::time::Duration; -/// How long [`ask_daemon_to_sync`] waits for the daemon to report an outcome, and the -/// budget for the no-daemon CLI fallback. Named so the log field, the user-facing -/// message and the timer can never disagree — they were three independent literals, -/// and a `30` that drifts in one place turns a support report into a wrong-duration -/// red herring. +/// How long `meridian tasks-sync` gets before the command gives up and reports +/// a timeout. Named so the log field, the user-facing message and the timer can +/// never disagree — they were three independent literals, and a `30` that drifts +/// in one place turns a support report into a wrong-duration red herring. const SYNC_TIMEOUT: Duration = Duration::from_secs(30); /// Success payload — mirrors the route's `{ ok, detail }` (the CLI's stdout). @@ -76,289 +33,83 @@ pub struct SyncResult { pub detail: String, } -/// How often [`ask_daemon_to_sync`] re-reads the request row while waiting. The -/// daemon's watcher ticks every 2 s, so this is matched to that rather than being -/// tighter — a faster poll would just burn reads without seeing a result any sooner. -const OUTCOME_POLL_INTERVAL: Duration = Duration::from_millis(500); - -/// Turn a failed request-write into something a user can act on. -/// -/// # Why the missing-table case is special-cased -/// -/// `pm_sync_requests` arrives in migration 082, and **only the daemon runs migrations** -/// (the tray opens the file with `create_if_missing(false)` and assumes the daemon made -/// it). So during an app update there is a window — new tray already running, daemon not -/// yet restarted onto the new binary — where the table genuinely does not exist yet. -/// -/// It is seconds long and self-heals the moment the daemon restarts, but a user who -/// presses "Sync now" inside it would otherwise be shown a raw SQL string: -/// `could not queue the sync: no such table: pm_sync_requests`. That reads like -/// database damage for what is in fact a normal, transient update state, and it is the -/// kind of message that produces a support ticket about a non-problem. -/// -/// Matched on the message rather than a typed error because sqlx surfaces this as a -/// `Database` error whose only distinguishing feature IS its text; the match is -/// deliberately loose (table name plus "no such table") so a reworded sqlite message -/// degrades to the generic branch rather than mis-reporting something else. -fn queue_failure_message(e: &anyhow::Error) -> String { - let detail = format!("{e:#}"); - if detail.contains("no such table") && detail.contains("pm_sync_requests") { - tracing::warn!("pm_sync_requests missing - the daemon has not applied migration 082 yet"); - return "Meridian is still finishing an update - try again in a moment".to_string(); - } - tracing::warn!(error = %detail, "could not queue a PM sync request"); - format!("could not queue the sync: {detail}") -} - -/// Resolve the tray's DB handle, or an error string suitable for returning straight -/// to the frontend. `None` means the pool is closed (a repair or a corrupt DB), which -/// is a real condition rather than a bug — say so plainly instead of unwrapping. -fn require_pool( - pool: &tauri::State<'_, crate::db_pool::DbPool>, -) -> Result { - pool.get() - .ok_or_else(|| "the database is not open - Meridian may be repairing it".to_string()) -} - -/// Re-sync the board from the tracker (the ported /api/tasks/sync POST) — always -/// forces a fetch, bypassing the per-provider staleness gate, because the user -/// explicitly asked for fresh data right now. -/// -/// Asks the daemon rather than syncing here. The tray must never hold the tracker -/// credential: the Jira refresh token is single-use and rotating, so two processes -/// spending it can permanently kill the grant, and the daemon is the designated sole -/// owner (see [`meridian_core::pm_sync_requests`]). This writes a `force` request, -/// then polls the row for the outcome so the button can still report a real result. -#[tauri::command] -#[tracing::instrument(skip(pool))] -pub async fn sync_tasks( - pool: tauri::State<'_, crate::db_pool::DbPool>, -) -> Result { - let db = require_pool(&pool)?; - match ask_daemon_to_sync(&db, SyncMode::Force, "sync_now", "tasks-sync").await? { - Some(result) => Ok(result), - // The user pressed a button and is watching a spinner, so a timeout has to - // SAY something — but not "failed": the request is queued and the daemon will - // service it. This is the one caller that reports the wait as an error. - None => Err(format!( - "the sync is still running after {}s - it will finish in the background", - SYNC_TIMEOUT.as_secs() - )), - } -} - -/// Frontend-facing: request a **gated** sync and wait for the outcome. -/// -/// # Only two screens should call this -/// -/// The board is rendered in many places, but only two of them make a *decision from -/// the whole task list*, and a stale board there is not a cosmetic lag: -/// -/// - **The daily plan**, where the user picks the day's tickets. A ticket assigned an -/// hour ago is simply ABSENT from the list they can pick from, so it cannot enter -/// the plan at all. (`PlanView`, on mount.) -/// - **The retarget / match-to-existing-ticket picker**, which lists every open -/// ticket by definition. (`WorklogTicketPicker`, on open.) -/// -/// Everything else showing the board is display and self-corrects on the next real -/// trigger. In particular **worklog drafting does NOT need this**: matching reads the -/// day's *plan* as its candidate pool, not the board (see -/// `src/pm_worklog/generate.rs::fetch_plan_candidates` — "Not the board"), so a -/// fresher board cannot widen the candidate set by even one ticket. -/// -/// # Gated, and never on a poll -/// -/// `Gated` rather than `Force` because these fire on mount/open, which recurs: the -/// daemon applies the per-provider staleness window, so a re-open inside it costs one -/// coalescing UPSERT instead of a tracker call. `sync_tasks` is the forced variant and -/// belongs to buttons the user pressed. -/// -/// **Never wire this to a polled read.** `PlanView` re-loads every 30 s and -/// `TasksPanel` every 60 s; attaching a sync to those would rebuild the -/// background-timer problem this whole design removed, relocated into the read path. -/// Mount and click are one-shot; a poll is not. +/// Re-sync the board from the tracker (the ported /api/tasks/sync POST). Spawns +/// `meridian tasks-sync` with a `SYNC_TIMEOUT` budget; returns its trimmed stdout as +/// `detail` on success, or an `Err` carrying stderr (the route's 500 body.error) +/// on timeout / spawn failure / non-zero exit. #[tauri::command] -#[tracing::instrument(skip(pool))] -pub async fn request_gated_sync_tasks( - pool: tauri::State<'_, crate::db_pool::DbPool>, -) -> Result { - let db = require_pool(&pool)?; - // A timeout here is NOT an error, unlike [`sync_tasks`]. Nothing is watching a - // spinner: the screen already rendered from cache and only wanted fresher rows. - // Returning `Err` would make the frontend log a failure for a sync that is simply - // still going, which is noise in the telemetry spool rather than information. - Ok( - ask_daemon_to_sync(&db, SyncMode::Gated, "plan_or_picker", "pm-sync") - .await? - .unwrap_or(SyncResult { - ok: false, - detail: "still running in the background".to_string(), - }), - ) -} - -/// Ask the daemon to sync and wait up to [`SYNC_TIMEOUT`] for its outcome. -/// -/// `Ok(Some(_))` the daemon reported a result; `Ok(None)` the budget elapsed with the -/// request still queued (not a failure - the daemon will service it); `Err` the sync -/// itself failed, or the outbox could not be read. -/// -/// Shared by [`sync_tasks`] and [`request_gated_sync_tasks`], which differ only in -/// their mode, their tag, their no-daemon fallback subcommand, and what they make of a -/// timeout. Those four params are the entire difference; everything else - the daemon -/// probe, the fallback, the request write, the poll loop - was duplicated line for line -/// between them, which is how the two drifted into having different log messages and -/// only one of them being `#[instrument]`ed. -/// -/// `fallback_cli` is the `meridian` subcommand that performs this same sync in its own -/// process. It is used only when no daemon owns the data dir, where there is no second -/// writer to race for the rotating credential - the tray still never holds a token -/// itself. Without it, a queued row would sit unserviced and the user would watch a -/// spinner time out with the daemon stopped. -#[tracing::instrument(skip(db), fields(mode = mode.as_str()))] -async fn ask_daemon_to_sync( - db: &SqlitePool, - mode: SyncMode, - reason: &'static str, - fallback_cli: &'static str, -) -> Result, String> { - if !crate::commands::daemon_control::status().await.running { - tracing::debug!( - fallback_cli, - "no daemon - delegating to the CLI's own fallback" - ); - let out = - crate::commands::cli_exec::run_meridian(&[fallback_cli], SYNC_TIMEOUT, fallback_cli) - .await?; - return Ok(Some(SyncResult { - ok: true, - detail: out.trim().to_string(), - })); - } - - if let Err(e) = pm_sync_requests::request(db, ALL_PROVIDERS, mode, reason).await { - return Err(queue_failure_message(&e)); - } - - let deadline = tokio::time::Instant::now() + SYNC_TIMEOUT; - loop { - if tokio::time::Instant::now() >= deadline { +#[tracing::instrument] +pub async fn sync_tasks() -> Result { + let bin = crate::install::meridian_bin(); + // The cwd picks the credentials, because dotenvy walks up from it: a release + // build lands on the canonical ~/.meridian/.env (AZURE_DEVOPS_PAT, JIRA_URL, …), + // a dev build on the checkout's own. Never inherit the tray's cwd — under a + // packaged .app that is inside the bundle, and dotenvy finds no .env at all. + let cwd = crate::install::cli_cwd()?; + // WHICH binary ran, and from where, are the two facts that make a failure here + // legible: a stale installed CLI against a DB the dev daemon migrated ahead + // exits non-zero with nothing but `status=Some(1)` in the log otherwise. + tracing::debug!(bin = %bin, cwd = %cwd.display(), "tasks-sync: spawning"); + let child = tokio::process::Command::new(&bin) + .arg("tasks-sync") + .current_dir(&cwd) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + // On timeout below, `tokio::time::timeout` drops the output future; without + // this the orphaned `meridian tasks-sync` keeps running (and can still mutate + // the board) after the UI reports a failure. The deleted /api/tasks/sync route + // called child.kill() on its 30s timer — kill_on_drop preserves that contract. + .kill_on_drop(true) + .no_window() + .output(); + + let output = match tokio::time::timeout(SYNC_TIMEOUT, child).await { + Err(_) => { + // `kill_on_drop` reaps the child here, taking its stderr with it, so + // this log is the ONLY record a timeout ever leaves. Emitting a bare + // "tasks-sync timed out" (as it did) makes the two cases that matter + // indistinguishable in a support bundle: a genuinely slow tracker + // sync vs. a `meridian` binary that never got past opening a corrupt + // meridian.db. WHICH binary and WHICH cwd is what separates them — + // the same two facts the spawn/non-zero arms below already log. tracing::warn!( - timeout_s = SYNC_TIMEOUT.as_secs() as i64, - "daemon did not report a sync outcome in time" + bin = %bin, + cwd = %cwd.display(), + timeout_s = SYNC_TIMEOUT.as_secs(), + "tasks-sync timed out" ); - return Ok(None); + return Err(format!( + "tasks-sync timed out after {}s", + SYNC_TIMEOUT.as_secs() + )); } - tokio::time::sleep(OUTCOME_POLL_INTERVAL).await; - - match pm_sync_requests::outcome(db, ALL_PROVIDERS).await { - Ok(Some(out)) => { - if let Some(err) = out.error { - tracing::warn!(error = %err, "daemon reported a sync failure"); - return Err(err); - } - let detail = match out.synced_count { - Some(n) => format!("synced {n} task(s)"), - None => "synced".to_string(), - }; - tracing::debug!(detail = %detail, "sync ok"); - return Ok(Some(SyncResult { ok: true, detail })); - } - Ok(None) => continue, - Err(e) => return Err(format!("could not read the sync outcome: {e}")), + Ok(Err(e)) => { + tracing::warn!(bin = %bin, error = %e, "tasks-sync spawn failed"); + return Err(format!("spawn error: {e}")); } - } -} - -/// Fire-and-forget: ask the daemon to **force** a fetch (bypassing the staleness -/// gate) — for a moment where there is no stale cache to protect, e.g. a tracker was -/// just connected and the board should populate immediately rather than wait for the -/// next on-demand trigger. -/// -/// There is deliberately no `trigger_background_pm_sync` (gated) sibling any more. It -/// existed for the dashboard window openers, and those were removed: a window opening -/// is not evidence anyone is about to make a decision from the whole board. The two -/// screens that genuinely are — the daily plan and the retarget ticket picker — ask -/// for themselves through [`request_gated_sync_tasks`]. See that command's doc. -pub(crate) fn trigger_background_pm_force_sync(db: Option, reason: &'static str) { - request_sync(db, SyncMode::Force, reason); -} - -/// Write a sync request, fire-and-forget. -/// -/// Unlike [`sync_tasks`] this does NOT fall back to the CLI when no daemon is running. -/// These fire from window-open and connect-success paths where nothing is waiting on a -/// result, so a queued row that the next daemon start services is the right outcome - -/// spawning a process per window open is exactly the cost this replaced. -/// -/// Takes `Option` (i.e. `DbPool::get()`) rather than a pool or an -/// `AppHandle`, because `None` is a real state and not an error: the pool is closed -/// while a corrupt DB is being repaired. Callers pass what they already hold, which -/// is a `DbPool` in the integration paths and app state in the window paths. -/// -/// Best-effort by design: these fire from window-open and connect-success paths -/// where a failure must never block the thing the user asked for, and the next -/// trigger (or their explicit "Sync now") retries anyway. -fn request_sync(db: Option, mode: SyncMode, reason: &'static str) { - let Some(db) = db else { - tracing::debug!(reason, "pm sync request skipped - database not open"); - return; + Ok(Ok(o)) => o, }; - tauri::async_runtime::spawn(async move { - match pm_sync_requests::request(&db, ALL_PROVIDERS, mode, reason).await { - Ok(()) => tracing::debug!(reason, mode = mode.as_str(), "pm sync requested"), - Err(e) => tracing::debug!(reason, error = %e, "pm sync request failed"), - } - }); -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The update window: `pm_sync_requests` does not exist yet because the daemon - /// has not applied migration 082. The user must see a transient-update message, - /// never a raw SQL string that reads like database damage. - #[test] - fn a_missing_requests_table_reads_as_a_pending_update() { - let e = anyhow::anyhow!( - "error returned from database: (code: 1) no such table: pm_sync_requests" - ); - - let msg = queue_failure_message(&e); - assert_eq!( - msg, - "Meridian is still finishing an update - try again in a moment" + if output.status.success() { + let detail = String::from_utf8_lossy(&output.stdout).trim().to_string(); + tracing::info!("tasks-sync ok"); + Ok(SyncResult { ok: true, detail }) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + // Log the CLI's own reason, not just the exit code. `status=Some(1)` alone + // says nothing — the failure is always explained in stderr, and dropping it + // turned a one-line diagnosis into a manual re-run of the subcommand. + tracing::warn!( + status = ?output.status.code(), + bin = %bin, + stderr = %stderr, + "tasks-sync non-zero" ); - assert!(!msg.contains("no such table"), "must not leak SQL: {msg}"); - } - - /// Any OTHER write failure keeps its detail. Collapsing every error into the - /// friendly update message would hide a real fault (a locked or corrupt DB) behind - /// "try again in a moment", which never resolves. - #[test] - fn other_failures_keep_their_detail() { - let e = anyhow::anyhow!("database is locked"); - - let msg = queue_failure_message(&e); - - assert!( - msg.contains("database is locked"), - "detail was dropped: {msg}" - ); - } - - /// A missing table that is NOT ours is somebody else's problem and must not be - /// reported as a pending update - that would send the user to wait out an update - /// that is already finished while the real fault goes unnamed. - #[test] - fn a_different_missing_table_is_not_reported_as_an_update() { - let e = anyhow::anyhow!("no such table: pm_tasks"); - - let msg = queue_failure_message(&e); - - assert!(msg.contains("pm_tasks"), "detail was dropped: {msg}"); - assert!(!msg.contains("finishing an update"), "misattributed: {msg}"); + Err(if stderr.is_empty() { + "tasks-sync failed".to_string() + } else { + stderr + }) } } diff --git a/tray/src-tauri/src/lib.rs b/tray/src-tauri/src/lib.rs index 670b2e325..830fdc9a2 100644 --- a/tray/src-tauri/src/lib.rs +++ b/tray/src-tauri/src/lib.rs @@ -1372,7 +1372,6 @@ pub fn run() { // process / service control (ported /api process routes) commands::reload_daemon, commands::sync_tasks, - commands::request_gated_sync_tasks, commands::run_update, // tracker connect/disconnect (ported /api/integrations + /api/auth/oauth) commands::disconnect_integration, diff --git a/ui/components/plan/PlanView.tsx b/ui/components/plan/PlanView.tsx index 56645f625..3c2bb9fb6 100644 --- a/ui/components/plan/PlanView.tsx +++ b/ui/components/plan/PlanView.tsx @@ -25,7 +25,7 @@ import { dayString } from '@/components/timeline/types' import type { PlanResponse, IntegrationsResponse } from '@/lib/api-types' import { MAX_PLAN_TASKS } from '@/lib/api-types' import { load as bridgeLoad } from '@/lib/bridge' -import { lastSyncFailure, requestGatedTaskSync, syncTasks } from '@/lib/taskSync' +import { lastSyncFailure, syncTasks } from '@/lib/taskSync' import { availableTrackerNames, connectedTrackers } from '@/lib/integrations' import { usePlan, refreshPlan, planAction, pausePlanRefresh } from '@/components/plan/planStore' import { isTutorialRunning } from '@/components/tutorial/engine' @@ -219,35 +219,14 @@ export default function PlanView() { useEffect(() => { if (autoSyncedRef.current || !trackersLoaded || !data) return autoSyncedRef.current = true - // Nothing to pull: the wait is over before it started. - if (trackers.length === 0) { setFirstSyncDone(true); return } - // A POPULATED board still needs a refresh, just not a blocking one. - // - // This branch used to return immediately, on the reasoning that there was - // "something already there to show". True for the empty-board spinner this - // backstop exists for, but it left the planner as the one screen that picks - // from the whole task list while never asking for a current copy of it - so a - // ticket assigned an hour ago was absent from the list, and the only way to - // see it was noticing the Refresh chip. Absent, not stale: it cannot be put in - // today's plan at all. - // - // GATED, unlike the empty branch's forced `handleSync`: this fires on every - // planner open, so the per-provider staleness window is what keeps it from - // becoming a tracker call each time. Not awaited before `setFirstSyncDone` - - // the cached board renders now and the 30 s poll picks the fresher rows up. - if ((data.available?.length ?? 0) > 0) { - void requestGatedTaskSync().then((ok) => { if (ok) void load(false) }) - setFirstSyncDone(true) - return - } + // Nothing to pull, or something already there to show: the wait is over + // before it started. + if (trackers.length === 0 || (data.available?.length ?? 0) > 0) { setFirstSyncDone(true); return } // Usually this JOINS the sync the connect flow already started - see `handleSync`. // It stays as a backstop because plenty of empty-board arrivals never went near a // connect: a reopened app, a board that emptied, a sync that failed an hour ago. handleSync().finally(() => setFirstSyncDone(true)) - // `load` is in the deps because the populated branch above calls it directly. - // Re-running is harmless either way: `autoSyncedRef` makes the whole effect - // one-shot per mount. - }, [data, trackers, trackersLoaded, handleSync, load]) + }, [data, trackers, trackersLoaded, handleSync]) // The drag hold-off is module-global, so a planner closed mid-drag (onDragEnd // never fires) would strand every reader's refresh paused for the rest of the diff --git a/ui/components/timeline/WorklogTicketPicker.tsx b/ui/components/timeline/WorklogTicketPicker.tsx index 94d122d6c..394963338 100644 --- a/ui/components/timeline/WorklogTicketPicker.tsx +++ b/ui/components/timeline/WorklogTicketPicker.tsx @@ -23,7 +23,6 @@ import { useEffect, useMemo, useState } from 'react' import { load } from '@/lib/bridge' -import { requestGatedTaskSync } from '@/lib/taskSync' import type { BoardTicket } from '@/lib/api-types' /** Rank the board against a query: key first (people type "KAN-3"), then title, @@ -65,22 +64,9 @@ export function WorklogTicketPicker({ current, busy, onPick, onCancel, title, ex useEffect(() => { if (tickets) return let alive = true - const read = () => load('/api/board-tickets', 'get_board_tickets') + load('/api/board-tickets', 'get_board_tickets') .then(r => { if (alive) setFetched(excludeLocal ? r.filter(t => t.provider !== 'local') : r) }) .catch(() => { if (alive) setError('Could not load your board - try again in a moment.') }) - - // Show the cached board immediately, then ask for a refresh and re-read. - // - // This picker is the ONE surface whose entire purpose is choosing from the whole - // board, so a ticket missing from it is not a cosmetic lag - the user cannot - // retarget onto a ticket that is not listed, and nothing on screen suggests the - // list is incomplete. It had no refresh of its own and relied on whatever some - // other screen had happened to fetch. - // - // Ordered read-then-refresh-then-read on purpose: opening a picker must never - // wait on a network round trip. GATED, so opening it repeatedly stays inside the - // per-provider staleness window rather than hitting the tracker each time. - void read().then(() => requestGatedTaskSync()).then(ok => { if (ok && alive) void read() }) return () => { alive = false } }, [excludeLocal, tickets]) diff --git a/ui/components/timeline/settings/IntegrationsSection.tsx b/ui/components/timeline/settings/IntegrationsSection.tsx index 11e2ac66e..76fe3acb5 100644 --- a/ui/components/timeline/settings/IntegrationsSection.tsx +++ b/ui/components/timeline/settings/IntegrationsSection.tsx @@ -64,7 +64,7 @@ export function IntegrationsSection({ integrations, onChanged, gate = false, onD Connected to {connected.map(t => t.name).join(', ')}

- Kept in sync automatically + Syncing every hour

diff --git a/ui/lib/taskSync.ts b/ui/lib/taskSync.ts index 1e593fb1a..86fd88dc9 100644 --- a/ui/lib/taskSync.ts +++ b/ui/lib/taskSync.ts @@ -137,59 +137,3 @@ export function syncTasks(): Promise { export function pendingTaskSync(): Promise | null { return inFlight } - -/** - * Refresh the board for a screen that is about to make a decision from the WHOLE - * task list, and would behave differently against a stale one. - * - * # Only two screens should call this - * - * - **The daily plan**, where the user picks the day's tickets. A stale board here - * is not a cosmetic lag: a ticket assigned an hour ago is simply absent from the - * list they can pick from, so it cannot enter the plan at all. That is the - * candidate-starvation failure, and it is why this exists. - * - **The retarget / match-to-existing-ticket picker**, which lists every open - * ticket by definition. - * - * Worklog drafting deliberately does NOT call this: matching reads the day's *plan* - * as its candidate pool, not the board (`fetch_plan_candidates` - "Not the board"), - * so a fresher board cannot widen the candidate set by even one ticket. - * - * # Gated, unlike `syncTasks` - * - * `syncTasks` FORCES a fetch, which is right for a button the user pressed and wrong - * for a mount: a screen the user opens repeatedly would hit the tracker every time. - * This goes through `request_gated_sync_tasks`, so the daemon applies the - * per-provider staleness window and a re-open inside it costs one local UPSERT. - * - * **Never call this from a poll.** `PlanView` re-loads every 30 s and `TasksPanel` - * every 60 s; wiring it there would rebuild the background-timer problem this whole - * design removed, relocated into the read path. Mount and click are one-shot. - * - * NEVER REJECTS, for the same reason as `syncTasks`: resolves `true` on a completed - * sync and `false` on a failure. Callers that only want fresher rows can ignore the - * value; nothing on these screens should break because a refresh failed, since the - * cached board still renders. - * - * Deliberately NOT joined to `inFlight`: that promise tracks the *forced* sync, and - * a gated request is a different ask. It is cheap enough (one UPSERT when the window - * is closed) that sharing state would cost more in surprise than in requests. - */ -export function requestGatedTaskSync(): Promise { - return mutate<{ ok: boolean }>('/api/tasks/sync', 'request_gated_sync_tasks', {}) - // `ok: false` means the daemon had not reported back inside its budget - the sync - // is still running, so there is nothing fresher to re-read YET. Reported as - // `false` so callers skip a pointless re-read; it is not a failure and nothing is - // logged for it (unlike the `.catch` below). - .then((r) => r?.ok !== false) - .catch((e) => { - // Reported, not surfaced: unlike the Refresh chip there is no UI element - // waiting to explain this, and the screen renders fine from cache. It still - // has to reach the telemetry spool, or a board that is quietly never - // refreshing looks identical to one that is up to date. - const reason = syncFailureReason(e) - console.error('[meridian] gated tracker sync failed', e) - reportUiError(`gated tracker sync failed: ${reason}`) - return false - }) -} From 99799af6559f5ba6ade2b03ec5ee686225514faa Mon Sep 17 00:00:00 2001 From: adityaharishch Date: Thu, 27 Aug 2026 00:12:03 +0530 Subject: [PATCH 20/53] revert: keep migrations 082 and 083 after backing out the outbox The two reverts deleted these files along with the code that used them. They must stay. Both have already run on the staging testers' databases. `sqlx::migrate!` validates the applied set against the resolved files on every open, so a migration recorded as applied with no matching file makes the daemon fail to open the database at all - a hard boot failure for anyone who ran v1.91.0-staging.2 or .3. Deleting them would also break the repo's append-only migration rule. The `pm_sync_requests` table and its seq/completed_seq columns are simply unused now. An orphaned table costs nothing; a daemon that cannot open its own database costs everything. --- src/migrations/082_pm_sync_requests.sql | 68 ++++++++++++++++++++++ src/migrations/083_pm_sync_request_seq.sql | 35 +++++++++++ 2 files changed, 103 insertions(+) create mode 100644 src/migrations/082_pm_sync_requests.sql create mode 100644 src/migrations/083_pm_sync_request_seq.sql diff --git a/src/migrations/082_pm_sync_requests.sql b/src/migrations/082_pm_sync_requests.sql new file mode 100644 index 000000000..9a49eaaeb --- /dev/null +++ b/src/migrations/082_pm_sync_requests.sql @@ -0,0 +1,68 @@ +-- ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +-- +-- Single-owner PM sync: the request side of the outbox. +-- +-- WHY THIS EXISTS +-- An Atlassian OAuth refresh token is single-use and rotating: the old token dies +-- the instant the new one is issued, so a lost response leaves the grant +-- recoverable only inside a 10-minute window and unrecoverable after it. That +-- makes the token a resource with exactly one safe writer. +-- +-- It had several. The tray refreshed in-process, the daemon refreshed on its poll +-- loop, and the tray spawned `meridian pm-sync` / `tasks-sync` as fresh processes +-- that each refreshed too. Mutual exclusion was left to an advisory file lock that +-- could not actually deliver it: its 10 s timeout is shorter than the ~26 s a +-- refresh can take (3 attempts x 8 s + backoff), and on failure the code proceeded +-- WITHOUT the lock rather than backing off. Two processes could therefore spend the +-- same token, and the only thing preventing corruption was Atlassian's grace window +-- handing the loser the current pair. Correctness by vendor accident. +-- +-- So sync becomes a REQUEST rather than an action. Producers (tray window opens, +-- tracker connect, "Sync now", the CLI) write a row here; the daemon is the sole +-- consumer and the sole holder of the credential. +-- +-- COALESCING, NOT A QUEUE +-- `provider` is the PRIMARY KEY so repeated requests collapse into one pending row +-- (`ON CONFLICT DO UPDATE`). Opening the dashboard ten times must not queue ten +-- syncs - it must mean "a sync is wanted", once. `'*'` is the all-providers request +-- that every current producer writes; per-provider rows are reserved for a future +-- caller that needs to refresh just one board. +-- +-- `mode` escalates and never de-escalates while a row is pending: a 'force' request +-- landing on a pending 'gated' one upgrades it, because a user who just connected a +-- tracker or pressed "Sync now" must not have their explicit request downgraded by a +-- passing window focus. The reverse is silently ignored by the producer's UPSERT. +-- +-- COMPLETION IS REPORTED IN PLACE, NOT DELETED +-- The daemon stamps `completed_at` plus `error` / `synced_count` rather than removing +-- the row, so "Sync now" can show a real outcome without the tray needing to hold the +-- credential or shell out. A serviced row is retained until the next request replaces +-- it, which also gives `meridian health` a cheap, content-free view of the last sync +-- attempt. + +CREATE TABLE IF NOT EXISTS pm_sync_requests ( + -- '*' = all configured providers. A specific provider name scopes the request. + provider TEXT NOT NULL PRIMARY KEY, + -- 'gated' - honour the per-provider staleness window (the cheap common case). + -- 'force' - bypass it; the user explicitly asked (connect, "Sync now", CLI). + mode TEXT NOT NULL DEFAULT 'gated', + -- Free-text producer tag for tracing only (e.g. 'dashboard_open', 'token_connected'). + -- Never a user-content value: this is read back into logs. + reason TEXT NOT NULL DEFAULT '', + requested_at TEXT NOT NULL, + -- Set when the daemon starts servicing, so a request in flight is distinguishable + -- from one still waiting. Cleared on the next request. + claimed_at TEXT, + -- Set when the daemon finishes, success or failure. NULL while pending/in-flight. + completed_at TEXT, + -- NULL on success. The failure detail otherwise, for the tray to surface. + error TEXT, + -- Tasks refreshed on the last completed pass, for "Sync now" feedback. + synced_count INTEGER +); + +-- No secondary index on purpose. Every hot query (`claim`, `complete`, `outcome`, +-- `request`) filters on `provider`, which is the PRIMARY KEY, so an index on +-- (completed_at, claimed_at) would never be chosen. The only query that does not +-- name a provider is `reset_stale_claims`, which runs once per daemon boot over a +-- table holding one row per provider - a scan there is free. diff --git a/src/migrations/083_pm_sync_request_seq.sql b/src/migrations/083_pm_sync_request_seq.sql new file mode 100644 index 000000000..5c3cc7e76 --- /dev/null +++ b/src/migrations/083_pm_sync_request_seq.sql @@ -0,0 +1,35 @@ +-- ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity + +-- Give each PM sync request a monotonic sequence number, so a producer can tell +-- WHICH request an outcome belongs to. +-- +-- Migration 082 modelled the outbox as one row per provider whose completion was +-- signalled by `completed_at` going non-NULL, and `request()` cleared it so the row +-- "unambiguously represents work still to do". With one waiter that is fine. With +-- two it loses answers, and the tracker-connect flow always produces at least two: +-- `oauth_connected`, `token_connected` and the user's own "Sync now" all fire inside +-- a few seconds. +-- +-- Measured on 1.91.0-staging.2: request A is claimed and a real Jira sync starts; +-- request B lands mid-flight and nulls `claimed_at`/`completed_at`; the daemon +-- finishes and calls `complete()`, whose guard was `claimed_at IS NOT NULL` - now +-- NULL - so the outcome was DISCARDED and the sync re-run from scratch. Every waiter +-- then polled for its full 30 s budget and reported failure for a sync that had +-- actually succeeded, repeatedly. +-- +-- With a sequence, "done" is a watermark rather than a flag: a producer holding seq +-- N is satisfied by any `completed_seq >= N`, so overlapping requests coalesce +-- instead of cannibalising each other, and no completion can be misattributed to a +-- request that was never serviced. +-- +-- `seq` starts at 1 and `completed_seq` is NULL-means-nothing-completed, so +-- "pending" is `seq > COALESCE(completed_seq, 0)` everywhere. +ALTER TABLE pm_sync_requests ADD COLUMN seq INTEGER NOT NULL DEFAULT 1; +ALTER TABLE pm_sync_requests ADD COLUMN completed_seq INTEGER; + +-- Carry the existing row's state across rather than resetting it. A row already +-- completed under 082 must NOT read as pending after this migration (that would fire +-- a spurious provider sync on the first daemon start after an update, for every +-- installed user at once); a row mid-flight must stay pending so it is still +-- serviced. +UPDATE pm_sync_requests SET completed_seq = seq WHERE completed_at IS NOT NULL; From 0e4c505162c6ae406c438372954c492d63a45626 Mon Sep 17 00:00:00 2001 From: adityaharishch Date: Thu, 27 Aug 2026 00:49:52 +0530 Subject: [PATCH 21/53] fix(release): a revert must be able to cut a release Reverting #909 and #910 off pre-main produced three commits and a dry run that reported "Analysis of 4 commits complete: no release". The revert was merged and unshippable - the staging testers stayed on the exact build we had just backed out, with no way to reach them. Two reasons, both structural rather than a mistake in those commits: - `git revert` writes `Revert "Merge pull request #910 from ..."`, which is not a conventional-commit header at all, so it carries no type. - The `conventionalcommits` preset has no default release rule for the `revert` type, so even a well-formed `revert:` commit is inert. The release-notes generator in both configs ALREADY declares a "Reverts" section for the `revert` type, so the notes half of the pipeline expects reverts to appear in releases. Only the analyzer half disagreed. This is an oversight, not a decision. A revert is the case where shipping is most urgent - it is what you reach for when something in production is actively wrong - so it must not be the one thing the pipeline treats as unreleasable. Custom `releaseRules` are additive, not a replacement: commit-analyzer falls through to DEFAULT_RELEASE_RULES when no custom rule matches (index.js:63-67, "If no custom releaseRules or none matched the commit, try with default releaseRules"). Verified in the installed package rather than assumed, because getting this wrong would silently stop `feat` and `fix` from releasing. --- .releaserc.json | 8 +++++++- .releaserc.staging.json | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.releaserc.json b/.releaserc.json index 4b9f0f40c..42c46c99f 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -7,7 +7,13 @@ [ "@semantic-release/commit-analyzer", { - "preset": "conventionalcommits" + "preset": "conventionalcommits", + "releaseRules": [ + { + "type": "revert", + "release": "patch" + } + ] } ], [ diff --git a/.releaserc.staging.json b/.releaserc.staging.json index 9f8d2a7c3..3b923ed58 100644 --- a/.releaserc.staging.json +++ b/.releaserc.staging.json @@ -11,7 +11,13 @@ [ "@semantic-release/commit-analyzer", { - "preset": "conventionalcommits" + "preset": "conventionalcommits", + "releaseRules": [ + { + "type": "revert", + "release": "patch" + } + ] } ], [ From e8c4245fa58c094902907427ff196fb97652c2af Mon Sep 17 00:00:00 2001 From: Akarsh Hegde Date: Thu, 27 Aug 2026 17:13:28 +0530 Subject: [PATCH 22/53] fix: the CodeRabbit findings on #899, all of them in code I wrote Eight findings on the three PRs merged today. Seven fixed, one declined with a reason. The first two are the ones that mattered. ## The stand-down WARN reached nothing (main.rs) `daemon_already_running()` and `LockOutcome::HeldByAnother` both `return Ok(())` before `obs_guard.shutdown()`. `main` returning does not flush - `ObservabilityGuard` has no `Drop`, by design - so the WARN died in the batch processor. MEASURED, not argued: a second daemon was made to lose the lock race and `meridian logs` found ZERO occurrences of the WARN it had just printed. On a release build there is no stdout mirror either, so the event was invisible everywhere. That WARN is the only evidence two daemons raced PAST the endpoint probe. I described it as "the first direct evidence of how often this happens across the fleet"; it would have measured nothing. Both stand-downs now flush + drain, matching the repair-marker stand-down 50 lines above. 0 -> 1 occurrences after the fix. Pinned by `every_stand_down_flushes_before_returning`. ## The quit flush could hang the app forever (tray/lib.rs) `force_flush()` awaits blocking exporter work and the SpoolClient does synchronous filesystem writes. Unbounded, a wedged spool sits there while `ExitPhase::Stopping` holds every later `ExitRequested` - the app becomes permanently unquittable with no way back. That is exactly what `HeldExitGuard` exists to prevent, reached again through another door, in a function whose neighbouring `stop_for_quit` is bounded for the identical reason. Now bounded by `QUIT_FLUSH_BUDGET` (2s, shorter than the 5s stop budget: the flush protects a diagnostic, not user data). The ordering test now also asserts boundedness. ## The rest - `main.rs`: the bind-site comment claimed the lock makes a second process unreachable. True on `Acquired`, FALSE on `Unavailable`, which proceeds unlocked deliberately. Corrected - and it now warns against deleting the stale-socket handling on the strength of a lock that path does not hold. - `main.rs`: `pid` on the checkpoint FAILURE warn. The success line had it; the failure line - the one most worth attributing - did not. - `render.rs`: `log.target`/`busy_ns`/`idle_ns` were prefix-matched alongside the real `code.`/`thread.` namespaces, so `busy_ns_budget` or `log.target_override` would have been silently hidden. That is the exact bug this rendering was added to fix, reintroduced by the noise filter. Split into namespaces vs whole keys, with a near-match test. - `observability`: the logger arm discarded its flush error while the tracer arm printed one. Both report now. Deliberately `eprintln!`, NOT `tracing::error!` as suggested: this is the code that flushes the telemetry pipeline, so reporting its failure through that pipeline is circular - on the quit path the process exits microseconds later and the record would land in the batch that just failed. Also note `force_flush` returns `Vec>`, one per processor, not a `Result`. - `deploy-gateway.sh`: `--noproxy '*'`. curl honours http_proxy/ https_proxy, so an intercepting proxy's own 401 would satisfy a security assertion without the request reaching the gateway. Verified: https_proxy at a dead port gives 000, --noproxy gives the real 401. - `deploy-gateway.sh`: validate `$#`, not just `$1`. `--verify-only extra` ignored the extra word and a single EMPTY argument matched the `""` arm and deployed - the same class of bug the dispatch was added to fix. ## Declined Em-dashes in four log message bodies. CLAUDE.md's rule targets user-facing app text - window titles, wizard copy, button labels, notification bodies - and exempts comments/docs. Log bodies have used em-dashes throughout: 171 sites across the tree already. Changing four of them makes the codebase less consistent, not more, and sets no precedent. If the rule should extend to log messages that is a tree-wide sweep plus a lint, not four edits inside a release PR. ## Testing Every assertion mutation-proved: - flush removed from the lock stand-down -> the new test fails - all-prefix matching restored in render.rs -> the near-match test fails - timeout removed from the quit flush -> boundedness assertion fails The last one initially failed to COMPILE (dead_code on the now-unused constant), which reads as "0 failing" - the trap this repo has hit before. Re-run with `#[allow(dead_code)]` so the mutant built and the test actually ran and failed for the right reason. clippy --workspace --all-targets -D warnings clean; 27 test suites green. --- scripts/deploy-gateway.sh | 27 ++++++++- src/main.rs | 83 +++++++++++++++++++++++--- src/observability/mod.rs | 25 +++++++- src/telemetry_spool/render.rs | 48 +++++++++++++-- tray/src-tauri/src/daemon_lifecycle.rs | 35 +++++++++-- tray/src-tauri/src/lib.rs | 26 +++++++- 6 files changed, 223 insertions(+), 21 deletions(-) diff --git a/scripts/deploy-gateway.sh b/scripts/deploy-gateway.sh index 248b664aa..116ff34d9 100644 --- a/scripts/deploy-gateway.sh +++ b/scripts/deploy-gateway.sh @@ -94,7 +94,16 @@ probe_rejects_unauthenticated() { shift 2 local deadline=$((SECONDS + PROBE_TIMEOUT_S)) code verdict while :; do - code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$@" "${url}" || true)" + # --noproxy '*': the point of this probe is that THE GATEWAY rejects + # the caller. curl honours http_proxy/https_proxy/ALL_PROXY, so on a + # machine with a proxy configured an intercepting proxy's own 401 + # would satisfy the assertion without the request ever reaching the + # gateway - a security check passing on someone else's answer. + # Verified: with https_proxy pointed at a dead port the probe returns + # 000, and --noproxy '*' returns the gateway's real 401. If direct + # egress is genuinely impossible here, 000 retries and then FAILS, + # which is the intended "fail rather than falsely pass" behaviour. + code="$(curl -s --noproxy '*' -o /dev/null -w '%{http_code}' --max-time 15 "$@" "${url}" || true)" verdict="$(classify_probe "${code}")" case "${verdict}" in pass) @@ -151,6 +160,22 @@ verify_public_endpoints_authenticate() { # # A deploy now requires exactly zero arguments. Anything unrecognised prints # usage and exits 2 without touching the VM. +# `$#` first: the `case` below only ever inspects `$1`, so without this +# `--verify-only somethingelse` would silently ignore the extra word, and a +# single EMPTY argument (`"$SOME_UNSET_VAR"`) would match the `""` arm and +# deploy. Both are the same class of bug as the one this dispatch was added to +# fix - an argument that does not mean what it looks like, ending in a +# production deploy. +if [ "$#" -gt 1 ]; then + echo "deploy-gateway.sh: unexpected extra argument '${2}'" >&2 + echo "run with --help for usage" >&2 + exit 2 +fi +if [ "$#" -eq 1 ] && [ -z "${1}" ]; then + echo "deploy-gateway.sh: empty argument - a deploy takes NO arguments" >&2 + echo "run with --help for usage" >&2 + exit 2 +fi case "${1:-}" in --verify-only | --self-test | "") ;; -h | --help) diff --git a/src/main.rs b/src/main.rs index f52889729..fcc4b622d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1120,6 +1120,14 @@ async fn main() -> Result<()> { endpoint = %meridian::platform::endpoint_display(), "another meridian daemon already owns this data dir — exiting (single-instance guard)" ); + // Flush before returning, matching the repair-marker stand-down above. + // Without it this WARN dies in the batch processor: `main` returning + // does not flush (`ObservabilityGuard` has no Drop — see its docs), so + // the record of a stand-down never reached the spool at all. MEASURED, + // not theorised: a second daemon was made to lose the race and + // `meridian logs` found zero occurrences of its own WARN. + obs_guard.shutdown().await; + meridian::telemetry_spool::shipper::drain_once().await; return Ok(()); } @@ -1162,6 +1170,11 @@ async fn main() -> Result<()> { pid = std::process::id() as i64, "another meridian daemon holds the single-instance lock for this data dir — exiting (lock)" ); + // See the probe stand-down above. This one matters most: it is the + // ONLY evidence that two daemons raced past the probe, and it was + // reaching nothing. + obs_guard.shutdown().await; + meridian::telemetry_spool::shipper::drain_once().await; return Ok(()); } // Could not find out. Proceed UNLOCKED rather than refuse to start: @@ -1229,17 +1242,28 @@ async fn main() -> Result<()> { // would let a daemon that's about to `exit(1)` on a locked or corrupt // database falsely tell the tray's watchdog it's healthy for the // brief window before that failure surfaces. Safe to bind - // unconditionally here: we hold the single-instance lock taken at + // unconditionally here — but read the next paragraph before relying + // on WHY, because the obvious reason is only true most of the time. + // + // ON THE `Acquired` PATH we hold the single-instance lock taken at // 4a-quater, so no other daemon process reached this line at all. (The // older justification — "the check above established nothing else is // listening, and we're single-threaded up to the poll loop" — was only // ever about THIS process's threads and said nothing about a second - // process. The lock is what actually makes this claim true.) + // process.) + // + // ON THE `Unavailable` PATH WE HOLD NO LOCK. That path exists on + // purpose (a lock we could not attempt must not stop the daemon + // starting), and it reaches this line with exactly the pre-lock + // guarantees: the endpoint probe, which is check-then-act. So the + // lock does NOT make the sequence below unreachable in general — only + // on the path where it was actually acquired. // // NOTE: `spawn_health_listener` itself unlinks a stale socket and then - // binds, which is its own check-then-act across processes. The lock - // makes that unreachable for two daemons, but the sequence is left - // untouched here on purpose — #862 owns that boundary. + // binds, which is its own check-then-act across processes. Do not + // delete that stale-socket handling on the strength of the lock: on + // the `Unavailable` path it is still the only thing standing there. + // #862 owns that boundary. meridian::platform::spawn_health_listener()?; tracing::info!(endpoint = %meridian::platform::endpoint_display(), "daemon health endpoint ready"); @@ -1643,9 +1667,14 @@ async fn main() -> Result<()> { pid = std::process::id() as i64, "WAL checkpoint on shutdown complete" ), - Err(e) => { - tracing::warn!(error = %meridian::errors::chain(&e), "WAL checkpoint on shutdown failed - continuing anyway") - } + // `pid` here too: the whole reason both outcomes are logged is to + // attribute a checkpoint to a generation, and a FAILED checkpoint is + // the one most worth attributing. + Err(e) => tracing::warn!( + pid = std::process::id() as i64, + error = %meridian::errors::chain(&e), + "WAL checkpoint on shutdown failed - continuing anyway" + ), } meridian.close().await; @@ -1755,6 +1784,44 @@ mod startup_order_tests { /// `backend_install.rs` (`a_stuck_bootout_is_reported_at_warn`, /// `every_early_return_still_restores_the_daemon`) — this scans the /// source for the three call sites and asserts their relative order. + /// A stand-down must FLUSH before it returns, or its own WARN is lost. + /// + /// `main` returning does not flush: `ObservabilityGuard` has no `Drop` + /// (its docs say to call `shutdown` explicitly), so a record emitted just + /// before `return Ok(())` dies in the batch processor. + /// + /// This was not theoretical. MEASURED before the fix: a second daemon was + /// made to lose the lock race and `meridian logs` found ZERO occurrences of + /// the WARN the daemon had just printed. On a release build there is no + /// stdout mirror either, so the event was invisible everywhere - and that + /// WARN is the only evidence that two daemons raced past the endpoint + /// probe, which is the entire reason the lock reports it. + #[test] + fn every_stand_down_flushes_before_returning() { + const SRC: &str = include_str!("main.rs"); + let prod = SRC + .split_once("\n#[cfg(test)]") + .map_or(SRC, |(before, _)| before); + + for needle in ["exiting (single-instance guard)", "exiting (lock)"] { + let at = prod + .find(needle) + .unwrap_or_else(|| panic!("the {needle} stand-down must exist in main()")); + let after = &prod[at..]; + let ret = after + .find("return Ok(());") + .unwrap_or_else(|| panic!("the {needle} stand-down must return")); + let between = &after[..ret]; + assert!( + between.contains("obs_guard.shutdown().await;"), + "the '{needle}' stand-down must flush telemetry before it \ + returns - without it the WARN it just emitted never reaches \ + the spool and the stand-down is invisible. Found between the \ + log and the return: {between:?}" + ); + } + } + #[test] fn single_instance_lock_precedes_setup_db_and_bind_follows_it() { const SRC: &str = include_str!("main.rs"); diff --git a/src/observability/mod.rs b/src/observability/mod.rs index 30f593392..fdd75954d 100644 --- a/src/observability/mod.rs +++ b/src/observability/mod.rs @@ -134,7 +134,13 @@ impl ObservabilityGuard { } if let Some(lp) = self.logger_provider { let _ = tokio::task::spawn_blocking(move || { - let _ = lp.force_flush(); + // See `force_flush` for why this reports to stderr rather than + // through `tracing`. + for r in lp.force_flush() { + if let Err(e) = r { + eprintln!("observability: log force_flush error: {e:?}"); + } + } let _ = lp.shutdown(); }) .await; @@ -201,7 +207,22 @@ pub async fn force_flush() { } if let Some(lp) = handles.logger_provider.clone() { let _ = tokio::task::spawn_blocking(move || { - let _ = lp.force_flush(); + // Reported, not discarded - the tracer arm above already prints its + // error and the logger arm silently swallowed one. + // + // `eprintln!` rather than `tracing::error!` DELIBERATELY: this is + // the code that flushes the telemetry pipeline, so routing its own + // failure back into that pipeline is circular. On the quit path the + // process exits microseconds later, so a `tracing::error!` here + // would land in the very batch that just failed to flush and be + // lost - reporting the failure by the one mechanism the failure + // proves is broken. stderr is the only sink that does not depend on + // what is failing. + for r in lp.force_flush() { + if let Err(e) = r { + eprintln!("observability: log force_flush error: {e:?}"); + } + } }) .await; } diff --git a/src/telemetry_spool/render.rs b/src/telemetry_spool/render.rs index 636bc8a04..1f1983f77 100644 --- a/src/telemetry_spool/render.rs +++ b/src/telemetry_spool/render.rs @@ -62,13 +62,23 @@ pub struct RenderedRecord { /// EVERY record, which would triple the width of every line with the one thing /// a reader already knows (they can see the message). /// -/// Prefix-matched, so `code.filepath`/`code.lineno`/`code.namespace` are all -/// covered by one entry. -const UNPRINTED_ATTR_PREFIXES: &[&str] = &["code.", "log.target", "thread.", "busy_ns", "idle_ns"]; +/// Namespaces: every key under them is call-site metadata, so a prefix match +/// is correct and `code.filepath`/`code.lineno`/`code.namespace` are covered by +/// one entry. +const UNPRINTED_ATTR_NAMESPACES: &[&str] = &["code.", "thread."]; + +/// Whole keys. Deliberately NOT prefixes: these are single field names, and +/// matching them by prefix would silently swallow an emitter's own field that +/// merely starts the same way - `log.target_override`, `busy_ns_budget`, +/// `idle_ns_extra`. Hiding a field the author chose to record is precisely the +/// bug this rendering exists to fix, so it must not be reintroduced by the +/// filter meant to reduce noise. +const UNPRINTED_ATTR_KEYS: &[&str] = &["log.target", "busy_ns", "idle_ns"]; /// Is this a call-site metadata key rather than a value the emitter chose? fn is_unprinted_attr(key: &str) -> bool { - UNPRINTED_ATTR_PREFIXES.iter().any(|p| key.starts_with(p)) + UNPRINTED_ATTR_NAMESPACES.iter().any(|p| key.starts_with(p)) + || UNPRINTED_ATTR_KEYS.contains(&key) } /// Decode one spooled file into [`RenderedRecord`]s. The signal (logs vs @@ -578,6 +588,36 @@ mod tests { ); } + /// A near-miss on an exact-match key must NOT be suppressed. + /// + /// `log.target`, `busy_ns` and `idle_ns` are whole field names, not + /// namespaces. When they were prefix-matched alongside `code.`/`thread.`, + /// any emitter field that merely started the same way was silently hidden - + /// which is the exact failure this rendering was added to fix, reintroduced + /// by the filter meant to reduce noise. + #[test] + fn a_field_that_merely_starts_like_a_metadata_key_still_prints() { + for key in ["log.target_override", "busy_ns_budget", "idle_ns_extra"] { + assert!( + !is_unprinted_attr(key), + "{key} is an emitter field, not call-site metadata, and must be \ + displayed" + ); + } + // ...while the exact keys and the real namespaces stay suppressed. + for key in [ + "log.target", + "busy_ns", + "idle_ns", + "code.filepath", + "code.lineno", + "thread.id", + "thread.name", + ] { + assert!(is_unprinted_attr(key), "{key} is call-site metadata"); + } + } + /// A field declared on a span but never recorded arrives as an empty value. /// Rendering `outcome=` costs width and carries nothing. #[test] diff --git a/tray/src-tauri/src/daemon_lifecycle.rs b/tray/src-tauri/src/daemon_lifecycle.rs index 9ca58b572..764255017 100644 --- a/tray/src-tauri/src/daemon_lifecycle.rs +++ b/tray/src-tauri/src/daemon_lifecycle.rs @@ -86,6 +86,16 @@ use tracing::Instrument; /// the next launch reconciles the daemon regardless. const QUIT_STOP_BUDGET: Duration = Duration::from_secs(5); +/// How long a quit waits for the telemetry flush before exiting anyway. +/// +/// Shorter than [`QUIT_STOP_BUDGET`] on purpose: the flush is a local disk +/// write that normally completes in milliseconds, and the thing it protects is +/// a diagnostic record, not the user's data. Losing that record is a bad +/// outcome; a Quit that never completes is a worse one, and `ExitPhase::Stopping` +/// holds every subsequent `ExitRequested` - so an unbounded wait here leaves +/// the app with no path out at all. +pub(crate) const QUIT_FLUSH_BUDGET: Duration = Duration::from_secs(2); + /// Where the app is in its exit sequence. /// /// The quit path is not atomic - the daemon stop is async and the exit is not - @@ -942,12 +952,15 @@ mod tests { let stop_pos = spawn_body .find("stop_for_quit().await") .expect("the spawned task must call stop_for_quit"); - let flush_pos = spawn_body - .find("observability::force_flush().await") - .expect( - "the spawned stop task must flush telemetry before exiting, or \ + // Matched WITHOUT `.await` attached: the call is wrapped in a + // `tokio::time::timeout`, so the await belongs to the timeout rather + // than to `force_flush` itself. The over-specific needle failed the + // moment that bound was added - the test doing its job, but it was the + // needle that needed correcting, not the code. + let flush_pos = spawn_body.find("observability::force_flush(").expect( + "the spawned stop task must flush telemetry before exiting, or \ stop_for_quit's outcome never reaches the spool", - ); + ); let exit_pos = spawn_body .find("handle.exit(") .expect("the spawned task must exit the app"); @@ -958,5 +971,17 @@ mod tests { been reached yet, and after the exit it does not run at all. \ Found stop at {stop_pos}, flush at {flush_pos}, exit at {exit_pos}." ); + + // And it must be BOUNDED. `force_flush` awaits blocking exporter work + // and the spool does synchronous filesystem writes, so an unbounded + // await here can hang the quit indefinitely - and `ExitPhase::Stopping` + // holds every later `ExitRequested`, leaving the app unquittable with + // no way back. That is the failure mode `HeldExitGuard` exists to + // prevent, reached again through a different door. + assert!( + spawn_body[..exit_pos].contains("QUIT_FLUSH_BUDGET"), + "the telemetry flush on quit must be bounded by QUIT_FLUSH_BUDGET; \ + an unbounded await leaves no path out of ExitPhase::Stopping" + ); } } diff --git a/tray/src-tauri/src/lib.rs b/tray/src-tauri/src/lib.rs index 830fdc9a2..f366137a4 100644 --- a/tray/src-tauri/src/lib.rs +++ b/tray/src-tauri/src/lib.rs @@ -1483,7 +1483,31 @@ pub fn run() { // corruption report (quit is when the tray and the // daemon are most likely to overlap on meridian.db), // and it was the one guaranteed never to survive. - meridian::observability::force_flush().await; + // + // BOUNDED, for the same reason `stop_for_quit` is: + // a quit that hangs is worse than a record that is + // lost. `force_flush` awaits blocking exporter work + // and the SpoolClient does synchronous filesystem + // writes, so a wedged spool (full disk, a stalled + // network home dir) would otherwise sit here + // forever - and `ExitPhase::Stopping` holds every + // later `ExitRequested`, so the app would be + // permanently unquittable with no way back. That is + // the exact failure mode `HeldExitGuard` exists to + // prevent; an unbounded await here reintroduced it + // by another door. + if tokio::time::timeout( + daemon_lifecycle::QUIT_FLUSH_BUDGET, + meridian::observability::force_flush(), + ) + .await + .is_err() + { + tracing::warn!( + budget_s = daemon_lifecycle::QUIT_FLUSH_BUDGET.as_secs(), + "flushing telemetry on quit exceeded its budget - exiting anyway" + ); + } // Immediately before the exit, so the re-entrant // `ExitRequested` this triggers is the one and only // one allowed through. From 41f8a60353cef9821f9f87804c5f1ea5d2da904a Mon Sep 17 00:00:00 2001 From: adityaharishch Date: Thu, 27 Aug 2026 19:24:55 +0530 Subject: [PATCH 23/53] ci(release): pin the Windows runner and make a cold build loud v1.91.0-staging.4's Windows job compiled for 36 minutes against a cache that was sitting on `main` the whole time. It asked for v0-rust-windows-release-...-Windows_NT-x64-cd9df261-fb0b6063 and the 1305 MiB entry on `main` was v0-rust-windows-release-...-Windows_NT-x64-581b1cd0-fb0b6063 Same day, ci.yml's Windows jobs asked for `581b1cd0` and missed a `cd9df261` entry - the two workflows had swapped hashes overnight. Cargo.lock, rust-toolchain.toml, this workflow file and the job's `env:` block were all unchanged, so the drift is environmental: the Windows fleet was serving more than one image (`windows-2025-vs2026` observed in CI), and rust-cache hashes the CARGO_*/CC*/CFLAGS/CXX/CMAKE*/RUST* variables into that key component. Which runner you land on decides whether you start warm. Three changes: * Pin `windows-latest` -> `windows-2025`. macOS was already pinned to `macos-26`; Windows was the only unpinned runner. This removes the major image roll as a variable but does not freeze the weekly refresh, and cannot fix a fleet that is heterogeneous within one label - so it is a mitigation, not the fix. * Report the rust-cache outcome on both jobs. A miss emits an `::error::` annotation plus a `$GITHUB_STEP_SUMMARY` section carrying the hashed environment. This is the actual fix: the miss itself has always been invisible, written as `No cache found.` inside a collapsed log group while every check went green - the same silence that let cache-warm.yml write an unreadable key for this pipeline's entire history. Deliberately non-fatal: a cache miss must never block shipping a release, but it must be impossible to overlook. Credential- shaped values are filtered out of the dump. * `cache-on-failure: true` on both jobs. A run that compiles for 30 minutes and then dies in bundling, signing or upload currently throws the compile away, so the retry pays for it a second time. Also corrects a now-stale `windows-latest` reference in update.rs's docs. Not included, deliberately: ci.yml's `windows-latest` has the same exposure and is worth pinning too, but it is a separate concern from the release pipeline and belongs in its own change. --- .github/workflows/release-build.yml | 150 +++++++++++++++++++++++++++- tray/src-tauri/src/update.rs | 2 +- 2 files changed, 150 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 8e56dd450..20b6aefe8 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -519,6 +519,7 @@ jobs: # for v0-rust-macos-release-aarch64-apple-darwin" — so stale entries # stopped being reaped too, feeding the same eviction. - uses: Swatinem/rust-cache@v2 + id: rust-cache with: # shared-key REPLACES rust-cache's automatic job-based key. That # matters: by default `add-job-id-key` is true, so ci.yml's @@ -561,6 +562,70 @@ jobs: # nothing can restore from — which is exactly how the repo's 10 GiB # quota filled up and began evicting caches other jobs needed. save-if: ${{ github.ref == 'refs/heads/main' }} + # SAVE THE CACHE EVEN WHEN THE JOB FAILS. + # + # Defaults to false, so a run that compiles for 30 minutes and then + # dies in bundling, signing, notarization or upload throws that whole + # compile away, and the retry starts cold again - paying twice for one + # release. The compile is the expensive half and it SUCCEEDED; a later + # step failing says nothing about the artifacts it produced. A partial + # `target/` is not a hazard either: cargo fingerprints it correctly, + # which is the same property every incremental build already relies on. + cache-on-failure: true + + # A COLD BUILD MUST NOT BE SILENT. This is the release pipeline's oldest + # and most expensive failure mode, and it has never once announced itself. + # + # The shape every time: the cache key drifts or the entry is evicted, + # `No cache found.` is written into a COLLAPSED log group, the build takes + # 20-30 extra minutes, and every check goes green with correct binaries at + # the end. It has only ever been caught by someone happening to watch the + # clock - most recently 2026-08-27, when the Windows job compiled for 36 + # minutes while the cache it wanted sat unread on `main` (see the + # `runs-on` note in the windows job). The same silence let cache-warm.yml + # write an unreadable key for this pipeline's entire history. + # + # Deliberately an `::error::` ANNOTATION that does NOT fail the step. The + # asymmetry matters: a cache miss must never block shipping a release - + # the build is slow, not wrong, and failing here would turn a GitHub image + # roll into a release outage. But it must be impossible to overlook, so it + # lands as a red annotation on the run page and as a section in + # `$GITHUB_STEP_SUMMARY`, both of which sit ABOVE the logs rather than + # inside a collapsed group. + # + # The env dump is the other half, and it is what makes the NEXT occurrence + # cheap. rust-cache hashes the NAMES AND VALUES of every + # CARGO_*/CC*/CFLAGS/CXX/CMAKE*/RUST* variable into the key component that + # drifts, and nothing has ever recorded what those were on a run that + # missed - so each time the cause has to be re-derived from the cache API + # and guesswork. Printing them on a miss turns that into a diff between + # two runs. Values that look like credentials are dropped rather than + # printed; none of the hashed prefixes should carry one, and a build log + # is not the place to find out otherwise. + - name: Report the rust-cache outcome + run: | + if [ "${{ steps.rust-cache.outputs.cache-hit }}" = "true" ]; then + echo "rust-cache: HIT - this build starts warm." + exit 0 + fi + msg="rust-cache MISSED on ${{ runner.os }}: this build compiles from scratch (~35 min on Windows, ~20 min on macOS). The key drifted or the entry was evicted - compare the environment below against a run that hit." + echo "::error title=Cold Rust build (${{ runner.os }})::${msg}" + { + echo "### rust-cache miss - ${{ runner.os }}" + echo + echo "${msg}" + echo + echo "
Cache-key environment (rust-cache hashes these names and values)" + echo + echo '```' + env \ + | grep -E '^(CARGO|CC|CFLAGS|CXX|CMAKE|RUST)' \ + | grep -viE '(TOKEN|SECRET|PASSWORD|CREDENTIAL|_KEY)' \ + | sort || true + echo '```' + echo + echo "
" + } >> "$GITHUB_STEP_SUMMARY" - uses: actions/setup-node@v7 with: @@ -836,7 +901,25 @@ jobs: windows: name: Windows x86_64 needs: prepare - runs-on: windows-latest + # PINNED, not `windows-latest`. The macOS job is pinned to `macos-26` and + # this one was not, and that asymmetry is not cosmetic: on 2026-08-27 the + # release's Windows job asked for + # `...-Windows_NT-x64-cd9df261-fb0b6063` while a 1305 MiB entry sat on + # `main` under `...-Windows_NT-x64-581b1cd0-fb0b6063`, so it compiled for + # 36 minutes with the cache it needed one API call away. The same day's + # ci.yml Windows jobs asked for `581b1cd0` and missed a `cd9df261` entry - + # the two workflows had SWAPPED hashes overnight. Nothing in the repo + # changed (identical Cargo.lock, rust-toolchain, workflow file and env + # block), so the drift came from the runner image: the fleet was serving + # more than one Windows environment (`windows-2025-vs2026` observed), and + # rust-cache hashes the CARGO_*/CC*/CFLAGS/CXX/CMAKE*/RUST* vars, so which + # runner you land on decides whether you start warm. + # + # Pinning the label removes the 2022 -> 2025 major roll as a variable. It + # does NOT freeze the weekly image refresh, and it cannot fix a fleet that + # is heterogeneous within one label - which is exactly why this line is not + # treated as the fix. The cache-outcome report below is. + runs-on: windows-2025 timeout-minutes: 60 # same cap as macos — bound a hung build instead of falling back to GitHub's 360-minute default permissions: contents: write # uploads assets into the draft @@ -929,6 +1012,7 @@ jobs: # equivalent step for the measurement (5288 cache entries evicting the # tarball that actually matters, for a 0.00% Rust hit rate). - uses: Swatinem/rust-cache@v2 + id: rust-cache with: # Keyed by triple only, same reasoning as the macOS job — one warm # cache shared across every workflow that compiles this target. @@ -941,6 +1025,70 @@ jobs: # compiled OpenSSL-from-source + SQLCipher + the daemon and tray from # a stone-cold cache, 35.7 min of a 37 min job, every single time. save-if: ${{ github.ref == 'refs/heads/main' }} + # SAVE THE CACHE EVEN WHEN THE JOB FAILS. + # + # Defaults to false, so a run that compiles for 30 minutes and then + # dies in bundling, signing, notarization or upload throws that whole + # compile away, and the retry starts cold again - paying twice for one + # release. The compile is the expensive half and it SUCCEEDED; a later + # step failing says nothing about the artifacts it produced. A partial + # `target/` is not a hazard either: cargo fingerprints it correctly, + # which is the same property every incremental build already relies on. + cache-on-failure: true + + # A COLD BUILD MUST NOT BE SILENT. This is the release pipeline's oldest + # and most expensive failure mode, and it has never once announced itself. + # + # The shape every time: the cache key drifts or the entry is evicted, + # `No cache found.` is written into a COLLAPSED log group, the build takes + # 20-30 extra minutes, and every check goes green with correct binaries at + # the end. It has only ever been caught by someone happening to watch the + # clock - most recently 2026-08-27, when the Windows job compiled for 36 + # minutes while the cache it wanted sat unread on `main` (see the + # `runs-on` note in the windows job). The same silence let cache-warm.yml + # write an unreadable key for this pipeline's entire history. + # + # Deliberately an `::error::` ANNOTATION that does NOT fail the step. The + # asymmetry matters: a cache miss must never block shipping a release - + # the build is slow, not wrong, and failing here would turn a GitHub image + # roll into a release outage. But it must be impossible to overlook, so it + # lands as a red annotation on the run page and as a section in + # `$GITHUB_STEP_SUMMARY`, both of which sit ABOVE the logs rather than + # inside a collapsed group. + # + # The env dump is the other half, and it is what makes the NEXT occurrence + # cheap. rust-cache hashes the NAMES AND VALUES of every + # CARGO_*/CC*/CFLAGS/CXX/CMAKE*/RUST* variable into the key component that + # drifts, and nothing has ever recorded what those were on a run that + # missed - so each time the cause has to be re-derived from the cache API + # and guesswork. Printing them on a miss turns that into a diff between + # two runs. Values that look like credentials are dropped rather than + # printed; none of the hashed prefixes should carry one, and a build log + # is not the place to find out otherwise. + - name: Report the rust-cache outcome + run: | + if [ "${{ steps.rust-cache.outputs.cache-hit }}" = "true" ]; then + echo "rust-cache: HIT - this build starts warm." + exit 0 + fi + msg="rust-cache MISSED on ${{ runner.os }}: this build compiles from scratch (~35 min on Windows, ~20 min on macOS). The key drifted or the entry was evicted - compare the environment below against a run that hit." + echo "::error title=Cold Rust build (${{ runner.os }})::${msg}" + { + echo "### rust-cache miss - ${{ runner.os }}" + echo + echo "${msg}" + echo + echo "
Cache-key environment (rust-cache hashes these names and values)" + echo + echo '```' + env \ + | grep -E '^(CARGO|CC|CFLAGS|CXX|CMAKE|RUST)' \ + | grep -viE '(TOKEN|SECRET|PASSWORD|CREDENTIAL|_KEY)' \ + | sort || true + echo '```' + echo + echo "
" + } >> "$GITHUB_STEP_SUMMARY" - uses: actions/setup-node@v7 with: diff --git a/tray/src-tauri/src/update.rs b/tray/src-tauri/src/update.rs index 9ea4b38b1..af1082d87 100644 --- a/tray/src-tauri/src/update.rs +++ b/tray/src-tauri/src/update.rs @@ -284,7 +284,7 @@ const NO_PLATFORM_ASSET: &str = "no-platform-asset"; /// published update artifact in `latest.json`" rather than a real check /// failure. Concretely this is Windows ARM64 today: `scripts/package-updater-windows.sh` /// only ever writes the `windows-x86_64` / `windows-x86_64-nsis` keys (the -/// `.github/workflows/release-build.yml` `windows` job builds on `windows-latest`, +/// `.github/workflows/release-build.yml` `windows` job builds on `windows-2025`, /// i.e. x86_64, only — there is no ARM64 Windows release job), so an ARM64 /// install's updater lookup can never match anything the manifest carries. /// From b4f33de7f9c1e59ba9110f28cdaceb5b3c27ee81 Mon Sep 17 00:00:00 2001 From: Akarsh Hegde Date: Thu, 27 Aug 2026 20:31:49 +0530 Subject: [PATCH 24/53] fix(telemetry): stop subprocess stderr reaching central OO through the log body Closes #872. The redaction allowlist governs attribute KEYS. The message body is not an attribute - it IS the record, so it always ships, having passed only scrub_text's URL/email/blob patterns, which know nothing about ticket keys or a tracker's error payload. Three tray call sites spliced a `meridian` subprocess's stderr straight into a WARN body, and #872 measured the result in central OpenObserve: a real user's ticket key (ENG-7041), from a value the allowlist explicitly denies as an attribute. The stderr moves to `stderr_tail`, deliberately NOT allowlisted. That is the two-tier design working rather than a diagnostic being thrown away: unallowlisted attributes are captured at full fidelity locally (`meridian logs` renders them, export bundles carry the raw spool) and dropped on the ship leg. The engineer debugging their own machine loses nothing. The shipped record also gains what it was silently missing: `code` was `code = ?Option`, which debug-formats to the string "Some(1)" - not a bare number, so `is_bare_number` could not rescue it and the exit code was dropped from every shipped record. Passed as i64 it is kept unconditionally. Rejected: an identifier regex over the body ([A-Z]{2,10}-\d+). It is the most common shape in technical prose - UTF-8, SHA-256, RFC-7396, CVE-2024-1234, x86-64 - so the exclusion list is unbounded and every miss is a silent diagnostic loss, i.e. #867 re-created in the body. It would also make scrub_text look like it handles identifiers, so the next splice goes unexamined. Enforced, not just fixed: src/log_hygiene.rs walks every workspace source tree and fails the build on a new interpolated WARN+/ERROR body, with INTERPOLABLE as the justified exemption list. It matches BARE `warn!(` too - 14 sites in etl/ and capture/ import the macro, and a scan anchored on `tracing::warn!` would have read as full coverage while skipping all of them. Both new assertions are mutation-proved: restoring the pre-fix body fails the cli_exec test with the measured leak verbatim, and fails the repo-wide scan from a different crate; adding stderr_tail to SAFE_STRING_KEYS fails both guards. privacy.md said error reports never contain ticket keys while describing only the attribute filter, never mentioning that the message itself is sent. Both corrected to the fixed state. cli_exec.rs and the new lint would have pushed two files past the 500-line rule, so the tests are split into cli_exec_tests.rs and the lint lives in its own module rather than growing errors.rs. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 22 +- docs/privacy.md | 3 +- src/lib.rs | 4 + src/log_hygiene.rs | 361 ++++++++++++++++++ src/telemetry_spool/redact.rs | 83 ++++ tray/src-tauri/src/commands/cli_exec.rs | 220 ++++------- tray/src-tauri/src/commands/cli_exec_tests.rs | 274 +++++++++++++ tray/src-tauri/src/commands/parents.rs | 14 +- tray/src-tauri/src/commands/triage.rs | 12 +- tray/src-tauri/src/lib.rs | 8 +- 10 files changed, 854 insertions(+), 147 deletions(-) create mode 100644 src/log_hygiene.rs create mode 100644 tray/src-tauri/src/commands/cli_exec_tests.rs diff --git a/CLAUDE.md b/CLAUDE.md index bceecf006..144b98011 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -385,7 +385,7 @@ supported way to read logs locally, replacing the old JSONL-tailing UI and the old bash `meridian logs` (which used to tail launchd-redirected stdout/stderr text). -**Three couplings that silently delete error coverage.** Each has bitten at +**Four couplings that silently delete error coverage - or cause an egress.** Each has bitten at least once; none fails loudly, and none is visible from the call site. 1. **The `EnvFilter` decides what is captured at all — before the spool, before @@ -420,7 +420,25 @@ least once; none fails loudly, and none is visible from the call site. the field was the cheaper half. Likewise a full binary path (`bin`) stays denied while `bin_source` — a closed set of literals from `install::bin_source` — ships in its place. -3. **A `u64` field is not shipped as a number.** `tracing-opentelemetry` 0.28's +3. **The log BODY is not an attribute, and nothing filters it.** The allowlist + governs attribute KEYS; the body *is* the record, so it always ships, having + passed only `scrub_text`'s URL/email/blob patterns — which know nothing about + ticket keys, window titles, or a tracker's error payload. CLAUDE.md has said + "structured fields — never format data values into the message string" from + the start, and nothing enforced it, so it drifted at three tray call sites + that spliced a `meridian` subprocess's stderr into a WARN body. Issue #872 + measured the result: a real user's ticket key (`ENG-7041`) in central + OpenObserve, from a value the allowlist denies as an attribute. It is now + enforced — `errors.rs::no_user_data_interpolated_into_a_log_body` walks every + workspace source tree and fails the build on a new interpolated WARN+/ERROR + body, with `INTERPOLABLE` as the deliberate, justified exemption list. **The + corollary when you fix one: move the value to an UNALLOWLISTED attribute + rather than deleting it.** Unallowlisted attributes are captured at full + fidelity locally (`meridian logs` renders them; export bundles carry the raw + spool) and dropped on the ship leg — that is the two-tier design working, and + it is why `stderr_tail` costs the engineer debugging their own machine + nothing. Deleting the value instead is #867's mistake wearing #872's clothes. +4. **A `u64` field is not shipped as a number.** `tracing-opentelemetry` 0.28's `Visit` impl has no `record_u64`, so `tracing::field::Visit`'s default applies and forwards to `record_debug` — which emits a **StringValue**. The field then misses the allowlist (nobody lists `timeout_s` as a *string* key) and is diff --git a/docs/privacy.md b/docs/privacy.md index 4e16df438..8d9bdc6c2 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -78,12 +78,13 @@ For the records that do qualify, every attribute is filtered on your device befo - **Text values are dropped unless the attribute name is on an explicit allowlist.** Anything path-like is scrubbed of your home directory; the small free-text subset (error messages, stack traces) is additionally scrubbed of URLs, email addresses, and token-shaped strings, then length-clamped. - **Structured values** (byte blobs, arrays, nested maps) are dropped outright. - **Span events and links are cleared entirely.** +- **The message itself is sent** - it is the error, so there is nothing to filter it against. Meridian's own rule is therefore that a message must be a fixed sentence and every runtime value must travel as a named field, where the allowlist above applies to it. That rule is enforced automatically: a build fails if any warning or error message formats a value into its text. The filter fails closed: a newly added attribute anywhere in the codebase is dropped by default until someone deliberately allowlists it. ### What is therefore never sent -OCR text, accessibility-tree content, window titles, browser URLs, coding-agent conversation bodies, LLM prompts and completions, ticket contents, file paths, and your local database. These stay on your machine even when error reporting is on. Local logs remain full-fidelity for your own debugging — the stripping applies only to the copy that would be transmitted. +OCR text, accessibility-tree content, window titles, browser URLs, coding-agent conversation bodies, LLM prompts and completions, ticket keys, ticket contents, file paths, and your local database. These stay on your machine even when error reporting is on. Local logs remain full-fidelity for your own debugging — the stripping applies only to the copy that would be transmitted. ### How reports are identified diff --git a/src/lib.rs b/src/lib.rs index 2c9b1c477..a78af526c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,10 @@ pub mod health; pub mod intelligence; pub mod llm; pub mod llm_experiment; +// Test-only lints on log content; see the module header. `#[cfg(test)]` because it +// has no runtime surface - it exists purely to fail the build. +#[cfg(test)] +mod log_hygiene; pub mod notices; pub mod notification_responses; pub mod notifications; diff --git a/src/log_hygiene.rs b/src/log_hygiene.rs new file mode 100644 index 000000000..bbf9ec8b9 --- /dev/null +++ b/src/log_hygiene.rs @@ -0,0 +1,361 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity + +//! Build-time lints on what a log record is allowed to CONTAIN. +//! +//! # Why this is a module and not a comment in a style guide +//! `telemetry_spool::redact` filters attributes by key: anything not on +//! `SAFE_STRING_KEYS` is dropped before the ship leg. The **message body** has +//! no such gate - it *is* the record, so it always ships, having passed only +//! `scrub_text`'s URL/email/blob patterns, which know nothing about ticket keys, +//! window titles, or a tracker's error payload. +//! +//! CLAUDE.md has said "structured fields - never format data values into the +//! message string" since the beginning, and nothing checked it. It drifted at +//! three tray call sites that spliced a `meridian` subprocess's stderr into a +//! WARN body, and issue #872 measured the result in central OpenObserve: a real +//! user's ticket key (`ENG-7041`), from a value the allowlist denies as an +//! attribute. A rule nothing enforces is a rule that decays, which is the whole +//! argument for putting it here instead. +//! +//! The lint is source-scanning because the defect is invisible at runtime - the +//! record is emitted, well-formed, and carries exactly what it was told to. +//! Nothing observes the difference until it is already on someone else's server. +//! +//! # Who calls this +//! Nothing at runtime. `cargo test` runs [`tests::no_user_data_interpolated_into_a_log_body`] +//! over every workspace source tree, and it fails the build for the whole +//! workspace, not just this crate. +//! +//! # Related +//! - [`crate::errors`] - the sibling log-hygiene lint, on the opposite failure: +//! `no_bare_error_display_in_db_paths` catches a cause being DROPPED, this +//! catches user data being ADDED. Both exist because a well-formed log line +//! is not a correct one. +//! - `crate::telemetry_spool::redact` - the attribute-side boundary this one +//! complements, and the reason a body needs its own rule at all. + +#[cfg(test)] +mod tests { + + /// The identifiers a WARN+/ERROR **message body** may interpolate. + /// + /// # Why this is an allowlist and not "don't do that" + /// A log body is the one field `telemetry_spool::redact` structurally cannot + /// filter. Attributes are dropped unless their key is on + /// `SAFE_STRING_KEYS`; the body IS the record, so it always ships. Anything + /// interpolated into it egresses verbatim, having passed only `scrub_text`'s + /// URL/email/blob patterns - which know nothing about ticket keys, window + /// titles, or a tracker's error payload. + /// + /// Issue #872 measured the consequence: a real user's ticket key + /// (`ENG-7041`) in central OpenObserve, spliced in from a `meridian` + /// subprocess's stderr by three tray call sites, from a value the allowlist + /// denies as an attribute. CLAUDE.md has forbidden this since the beginning + /// - "structured fields - never format data values into the message string" + /// - and nothing enforced it, so it drifted three times. + /// + /// # Adding an identifier + /// It must be provably a compile-time constant or a closed set of our own + /// literals at EVERY call site, and it must earn a justification here. If + /// the value is runtime data, it belongs in a structured field instead, + /// where the allowlist can make a deliberate decision about it - which is + /// the entire point of having one. + const INTERPOLABLE: &[&str] = &[ + // A subcommand/operation label. `&'static str` at every call site + // (`"ticket-statuses"`, `"plan-task-draft"`, …) - it names OUR + // subcommand, never the user's data. + "label", + // `catch_setup_panic`'s stage name - a `&str` literal at both call + // sites, naming a tray setup step. + "what", + // A panic payload, from `catch_setup_panic`. Deliberately still in the + // body: it is our own `expect`/`panic!` text and it is THE diagnostic + // for a startup panic, so moving it to an unallowlisted attribute would + // delete it from the fleet's telemetry entirely - #867's mistake, not + // #872's. It already ships today and this change does not widen that; + // the rename to `panic_msg` exists so the exemption is visible at the + // call site rather than riding on the generic name `msg`, which is what + // all three #872 sites used. + "panic_msg", + ]; + + /// The source trees this scans. A directory WALK, not an `include_str!` + /// list: the defect is a new call site, so a "remember to add your file" + /// list would be exactly the shape of hole issue #878 was about. Every + /// crate in the workspace that emits telemetry is covered. + const SCANNED_TREES: &[&str] = &[ + "src", + "meridian-core/src", + "meridian-oauth/src", + "tray/src-tauri/src", + ]; + + /// Collect `(path, production source)` for every `.rs` under `SCANNED_TREES`, + /// truncated at the file's test module - a file's own tests legitimately + /// contain example log lines, and this scan reads the file it lives in. + fn production_sources() -> Vec<(String, String)> { + fn walk(dir: &std::path::Path, out: &mut Vec<(String, String)>) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for e in entries.flatten() { + let p = e.path(); + if p.is_dir() { + walk(&p, out); + } else if p.extension().is_some_and(|x| x == "rs") + // A dedicated test file (`tests.rs`, `cli_exec_tests.rs`) + // has no in-file `#[cfg(test)]` to truncate at, so the + // marker heuristic below would read all of it as + // production and flag its example log lines. + && !p.file_name().is_some_and(|n| { + let n = n.to_string_lossy(); + n == "tests.rs" || n.ends_with("_tests.rs") + }) + { + if let Ok(src) = std::fs::read_to_string(&p) { + let prod = src + .split_once("\n#[cfg(test)]") + .map_or(src.as_str(), |(a, _)| a) + .to_string(); + out.push((p.display().to_string(), prod)); + } + } + } + } + // Set at COMPILE time to this crate's directory, which is the workspace + // root (`members = [".", …]`), so the walk is rooted correctly no matter + // where the test binary is invoked from. + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let mut out = Vec::new(); + for tree in SCANNED_TREES { + walk(&root.join(tree), &mut out); + } + assert!( + out.len() > 100, + "the source walk found only {} files - SCANNED_TREES is stale or the \ + layout moved, and a scan that reads nothing passes silently", + out.len() + ); + out + } + + /// Every `{ident}` interpolated into the message body of a `tracing::warn!` + /// or `tracing::error!` in `src`, with its line number. + fn interpolated_body_idents(src: &str) -> Vec<(usize, String, String)> { + let mut found = Vec::new(); + // Matched WITHOUT the `tracing::` prefix on purpose: 14 sites in + // `etl/` and `capture/` import the macro (`use tracing::warn`) and call + // it bare, and a scan anchored on the qualified spelling would have read + // as full coverage while silently skipping every one of them. + for macro_name in ["warn!", "error!"] { + let mut from = 0; + while let Some(rel) = src[from..].find(macro_name) { + let start = from + rel; + from = start + macro_name.len(); + // Not part of a longer identifier (`some_error!`, `debug_warn!`). + if src[..start] + .chars() + .next_back() + .is_some_and(|c| c.is_alphanumeric() || c == '_') + { + continue; + } + // Skip a commented-out example. + let line_start = src[..start].rfind('\n').map_or(0, |i| i + 1); + if src[line_start..start].trim_start().starts_with("//") { + continue; + } + let Some(open) = src[start..].find('(').map(|i| start + i) else { + continue; + }; + // Balance parens, ignoring anything inside a string literal. + let (mut depth, mut i) = (0usize, open); + let (mut in_str, mut esc) = (false, false); + let bytes: Vec = src[open..].chars().collect(); + let mut k = 0usize; + while k < bytes.len() { + let c = bytes[k]; + if in_str { + if esc { + esc = false; + } else if c == '\\' { + esc = true; + } else if c == '"' { + in_str = false; + } + } else if c == '"' { + in_str = true; + } else if c == '(' { + depth += 1; + } else if c == ')' { + depth -= 1; + if depth == 0 { + break; + } + } + k += 1; + } + i += k; + let args = &src[open + 1..i.min(src.len())]; + // The message is the LAST top-level string literal in the arg + // list - `tracing`'s own rule. + let Some(msg) = last_string_literal(args) else { + continue; + }; + let line = src[..start].matches('\n').count() + 1; + for ident in placeholders(&msg) { + found.push((line, ident, msg.clone())); + } + } + } + found + } + + /// The last `"…"` literal in a macro argument list, unescaped enough to read + /// its `{}` placeholders. + fn last_string_literal(args: &str) -> Option { + let chars: Vec = args.chars().collect(); + let (mut i, mut last) = (0usize, None); + while i < chars.len() { + if chars[i] == '"' { + let mut j = i + 1; + let mut buf = String::new(); + while j < chars.len() { + if chars[j] == '\\' { + j += 2; + continue; + } + if chars[j] == '"' { + break; + } + buf.push(chars[j]); + j += 1; + } + last = Some(buf); + i = j + 1; + } else { + i += 1; + } + } + last + } + + /// Named `{ident}` captures in a format string. Positional `{}` / `{:?}` are + /// out of scope: they take their value from a trailing argument, and + /// `no_bare_error_display_in_db_paths` already covers the `%e` case that + /// produces in practice. + fn placeholders(msg: &str) -> Vec { + let mut out = Vec::new(); + let chars: Vec = msg.chars().collect(); + let mut i = 0usize; + while i < chars.len() { + if chars[i] == '{' { + if chars.get(i + 1) == Some(&'{') { + i += 2; + continue; + } + let mut j = i + 1; + let mut name = String::new(); + while j < chars.len() && (chars[j].is_alphanumeric() || chars[j] == '_') { + name.push(chars[j]); + j += 1; + } + // Only a NAMED capture: `{x}` or `{x:?}`, not `{}` or `{:?}`. + if !name.is_empty() + && !name.starts_with(|c: char| c.is_ascii_digit()) + && matches!(chars.get(j), Some('}') | Some(':')) + { + out.push(name); + } + i = j; + } else { + i += 1; + } + } + out + } + + /// A WARN+ log BODY always ships - it is the one field the redaction + /// allowlist cannot reach - so nothing runtime-valued may be formatted into + /// it. See [`INTERPOLABLE`] for the full reasoning and issue #872 for the + /// leak that prompted this. + #[test] + fn no_user_data_interpolated_into_a_log_body() { + let mut offenders = Vec::new(); + for (path, src) in production_sources() { + for (line, ident, msg) in interpolated_body_idents(&src) { + if !INTERPOLABLE.contains(&ident.as_str()) { + offenders.push(format!("{path}:{line} - `{{{ident}}}` in \"{msg}\"")); + } + } + } + assert!( + offenders.is_empty(), + "a WARN+/ERROR message body interpolates a value that is not on \ + `INTERPOLABLE`. The body is the ONE field `telemetry_spool::redact` \ + cannot filter - attributes are dropped unless allowlisted, but the \ + body always ships - so whatever this formats in egresses verbatim \ + to central OpenObserve. Issue #872 measured a real user's ticket key \ + arriving this way. Put the value in a structured field instead \ + (`tracing::warn!(some_key = %v, \"static message\")`), where the \ + allowlist can make a deliberate decision about it; if it is genuinely \ + a compile-time literal at every call site, add it to `INTERPOLABLE` \ + with a justification. Offenders: {offenders:#?}" + ); + } + + /// The scan above is only worth having if it actually catches the #872 + /// shape. Feeds it the pre-fix source verbatim - a scan nobody has seen fail + /// is a scan nobody knows works. + #[test] + fn the_body_scan_catches_the_splice_it_was_written_for() { + let pre_fix = r#" + tracing::warn!( + bin = %bin, + code = ?output.status.code(), + "{label} non-zero: {msg}" + ); + "#; + let hits = interpolated_body_idents(pre_fix); + let idents: Vec<&str> = hits.iter().map(|(_, i, _)| i.as_str()).collect(); + assert!( + idents.contains(&"msg"), + "the scan missed `{{msg}}`, the exact splice #872 measured: {idents:?}" + ); + assert!( + idents.contains(&"label"), + "the scan missed `{{label}}` - it must SEE allowlisted idents too, or \ + the allowlist is doing nothing and a rename would slip through: {idents:?}" + ); + + assert!( + interpolated_body_idents(r#"warn!(error = %e, "gap {kind} misclassified");"#) + .iter() + .any(|(_, i, _)| i == "kind"), + "the scan missed a BARE `warn!(` - 14 sites in etl/ and capture/ \ + import the macro and call it unqualified" + ); + + // Things that must NOT trip it. + assert!( + interpolated_body_idents(r#"tracing::warn!(error = %e, "fetch failed");"#).is_empty(), + "a body with no interpolation was flagged" + ); + assert!( + interpolated_body_idents(r#"// tracing::warn!("{msg}");"#).is_empty(), + "a commented-out example was flagged" + ); + assert!( + interpolated_body_idents(r#"fn my_error!(x) { "{msg}" }"#).is_empty(), + "`my_error!` is a different macro and must not be scanned" + ); + assert!( + interpolated_body_idents(r#"tracing::warn!(n = 1, "dropped {} rows", n);"#).is_empty(), + "a positional `{{}}` placeholder is out of scope - see `placeholders`" + ); + assert!( + interpolated_body_idents(r#"tracing::warn!("100{{msg}} percent");"#).is_empty(), + "an escaped `{{{{`/`}}}}` literal was read as a placeholder" + ); + } +} diff --git a/src/telemetry_spool/redact.rs b/src/telemetry_spool/redact.rs index 48e5b622d..d3b81b92e 100644 --- a/src/telemetry_spool/redact.rs +++ b/src/telemetry_spool/redact.rs @@ -1820,6 +1820,89 @@ mod tests { } } + /// End-to-end proof of the #872 fix, at the boundary itself rather than at + /// the call site: a WARN shaped exactly like the one + /// `commands::cli_exec::log_non_zero_exit` now emits must ship its static + /// message and exit code, and must NOT ship the subprocess stderr the + /// pre-fix version spliced into that message. + /// + /// The pre-fix half is asserted too, and is the more important one - it is + /// what makes this a regression test rather than a restatement. Reverting + /// `log_non_zero_exit`'s body to `"{label} non-zero: {stderr}"` puts the + /// ticket key back in `body`, where nothing here can filter it, and this + /// test then fails on the very next line. + #[test] + fn a_subprocess_stderr_ships_only_when_it_is_spliced_into_the_body() { + const STDERR: &str = "Jira GET transitions for ENG-7041 returned 404 Not Found"; + + // The FIXED shape: static body, stderr on an unallowlisted attribute. + let mut fixed = log_record( + SEVERITY_WARN, + vec![ + str_attr("stderr_tail", STDERR), + str_attr("key", "ENG-7041"), + str_attr("bin_source", "staged"), + int_attr("code", 1), + int_attr("stderr_len", STDERR.len() as i64), + ], + ); + fixed.body = Some(AnyValue { + value: Some(Value::StringValue("ticket-statuses non-zero".into())), + }); + let out = match redact_and_filter("logs", &encode_logs(vec![fixed])) { + Redacted::Payload { bytes, .. } => decode_logs(&bytes), + Redacted::Empty => panic!("the WARN was filtered out before redaction"), + Redacted::Undecodable => panic!("the re-encoded payload did not decode"), + }; + let rec = out.first().expect("the WARN was dropped entirely"); + let body = match rec.body.as_ref().and_then(|b| b.value.as_ref()) { + Some(Value::StringValue(s)) => s.clone(), + other => panic!("expected a string body, got {other:?}"), + }; + assert!( + !body.contains("ENG-7041"), + "the ticket key reached the wire through the body: {body}" + ); + let keys: Vec<&str> = rec.attributes.iter().map(|kv| kv.key.as_str()).collect(); + assert!( + !keys.contains(&"stderr_tail") && !keys.contains(&"key"), + "stderr_tail / key are not allowlisted and must not ship: {keys:?}" + ); + // …but the record is still worth having: which subcommand, which + // binary, and - restored by this change, having previously been + // debug-formatted into `\"Some(1)\"` and dropped - the exit code. + assert!(body.contains("ticket-statuses"), "{body}"); + assert!(keys.contains(&"bin_source"), "{keys:?}"); + assert!( + keys.contains(&"code") && keys.contains(&"stderr_len"), + "the numeric diagnostics were dropped: {keys:?}" + ); + + // The PRE-FIX shape, for contrast: same stderr, spliced into the body. + // Nothing in this module can stop it, which is precisely why the guard + // had to go at the call site (`errors.rs`). + let mut pre_fix = log_record(SEVERITY_WARN, vec![]); + pre_fix.body = Some(AnyValue { + value: Some(Value::StringValue(format!( + "ticket-statuses non-zero: {STDERR}" + ))), + }); + let out = match redact_and_filter("logs", &encode_logs(vec![pre_fix])) { + Redacted::Payload { bytes, .. } => decode_logs(&bytes), + _ => panic!("expected a payload"), + }; + let body = match out[0].body.as_ref().and_then(|b| b.value.as_ref()) { + Some(Value::StringValue(s)) => s.clone(), + other => panic!("expected a string body, got {other:?}"), + }; + assert!( + body.contains("ENG-7041"), + "if redaction has started catching ticket keys in the body, this test \ + and `errors.rs`'s INTERPOLABLE doc are both out of date - the fix \ + would no longer need to live at the call site. Body was: {body}" + ); + } + /// `health check failed` is a static message carrying no diagnostic payload /// of its own — everything that identifies the fault rides on its fields. So /// six machines reported a CRITICAL health failure to the backend with no diff --git a/tray/src-tauri/src/commands/cli_exec.rs b/tray/src-tauri/src/commands/cli_exec.rs index 665bf881b..6b47eb560 100644 --- a/tray/src-tauri/src/commands/cli_exec.rs +++ b/tray/src-tauri/src/commands/cli_exec.rs @@ -107,19 +107,91 @@ pub(crate) async fn run_meridian( let msg = if stderr.is_empty() { format!("{label} exited {:?}", output.status.code()) } else { - stderr + stderr.clone() }; - tracing::warn!( - bin = %bin, - bin_source = crate::install::bin_source(&bin), - code = ?output.status.code(), - "{label} non-zero: {msg}" - ); + log_non_zero_exit(NonZeroExit { + label, + bin: Some(bin.as_str()), + provider: None, + key: None, + code: output.status.code(), + stderr: &stderr, + }); return Err(msg); } Ok(stdout) } +/// Everything the [`log_non_zero_exit`] WARN reports. A struct rather than six +/// parameters because clippy caps argument counts at seven and the three call +/// sites each carry a different subset (see `BlockBounds` in the daemon's ETL +/// runner for the same convention). +pub(crate) struct NonZeroExit<'a> { + /// The subcommand label, e.g. `ticket-statuses`. A `&'static str` literal at + /// every call site - it names OUR subcommand, never the user's data, which + /// is why it is the one value interpolated into the message body. + pub(crate) label: &'a str, + /// The resolved `meridian` binary path, when the caller has one. + pub(crate) bin: Option<&'a str>, + /// The tracker this call was for (`jira`, `linear`, …), when applicable. + pub(crate) provider: Option<&'a str>, + /// The ticket key the call was about, when applicable. Local-only - see the + /// note on `stderr_tail` in [`log_non_zero_exit`]. + pub(crate) key: Option<&'a str>, + /// The child's exit code, if it exited rather than being signalled. + pub(crate) code: Option, + /// The child's full stderr, already trimmed. + pub(crate) stderr: &'a str, +} + +/// The ONE place a non-zero `meridian` subprocess exit is logged. +/// +/// # Why this is not three inline `warn!`s +/// It used to be: this module, [`crate::commands::parents`] and +/// [`crate::commands::triage`] each spliced the child's stderr straight into the +/// message body (`"{label} non-zero: {msg}"`). A log **body** always ships - it +/// is the record - so it is the one field `telemetry_spool::redact`'s attribute +/// allowlist cannot reach, and subprocess stderr is unbounded third-party text: +/// a Jira 404 body, a provider API error, a DB error. Issue #872 measured a real +/// user's ticket key (`ENG-7041`) in central OpenObserve, arriving through +/// exactly this splice, from a key the allowlist denies as an attribute. +/// +/// So the body is now static and the stderr rides on `stderr_tail`, which is +/// deliberately **not** on `redact::SAFE_STRING_KEYS`. That is the two-tier +/// design working as intended rather than a diagnostic being thrown away: +/// unallowlisted attributes are captured at full fidelity locally (`meridian +/// logs` renders attributes, and export bundles carry the raw spool) and dropped +/// on the ship leg. The engineer debugging their own machine loses nothing; the +/// central backend stops receiving the user's content. +/// +/// `code` is passed as `i64` on purpose. It used to be `code = ?…`, which +/// debug-formats an `Option` into the string `"Some(1)"` - not a bare number, so +/// `redact::is_bare_number` could not rescue it and the exit code was dropped +/// from every shipped record. An `IntValue` is kept unconditionally for any key +/// ([`meridian::telemetry_spool::redact`]'s first `keep_attribute` arm), so this +/// change also *restores* a diagnostic the previous form silently lost. +pub(crate) fn log_non_zero_exit(ctx: NonZeroExit<'_>) { + let NonZeroExit { + label, + bin, + provider, + key, + code, + stderr, + } = ctx; + tracing::warn!( + bin = bin.unwrap_or(""), + bin_source = bin.map(crate::install::bin_source).unwrap_or(""), + provider = provider.unwrap_or(""), + key = key.unwrap_or(""), + // `-1` for "signalled, no exit code" - `Option` would stringify. + code = code.unwrap_or(-1) as i64, + stderr_len = stderr.len() as i64, + stderr_tail = %tail(stderr, 400), + "{label} non-zero" + ); +} + /// Spawn `meridian ` detached — no timeout, not awaited. For work that can /// far outlive a reasonable invoke budget (an N-variant LLM experiment); the UI /// polls the DB for progress instead of holding the call. A background task waits @@ -215,135 +287,5 @@ pub(crate) async fn run_meridian_json Deserialize<'de>>( } #[cfg(test)] -mod tests { - use std::process::Stdio; - use std::time::Duration; - - /// Regression guard: on a timeout, `tokio::time::timeout` drops the - /// `.output()` future, and without `kill_on_drop(true)` the spawned - /// `meridian ` keeps running in the background after the caller has - /// already reported failure to the user. For an LLM-backed call like - /// `plan-task-draft`, that orphan then competes with the next "Try again" - /// click's fresh process for the same provider/DB — a plausible reason a - /// draft that missed its 150s budget once keeps missing it on retry. - /// - /// This can't drive `run_meridian` itself as a real spawn-and-verify test: - /// it resolves its binary via `crate::install::meridian_bin()`, and - /// overriding that via `MERIDIAN_BIN` would mean `std::env::set_var` on a - /// shared test binary — exactly what `integrations.rs`'s "avoiding - /// `std::env::set_var` on a Tokio worker thread" note warns off. So this - /// is source-scanned, mirroring `tasks.rs::sync_tasks`, the sibling call - /// site that already carries this fix. The MECHANISM itself — that - /// `kill_on_drop(true)` actually terminates an orphaned child, on this - /// platform, with tokio's real process reaping — is verified separately - /// below, against a plain `tokio::process::Command` that needs no - /// `MERIDIAN_BIN` override at all. - #[test] - fn run_meridian_kills_the_child_on_timeout() { - let src = include_str!("cli_exec.rs"); - let prod = src.split_once("\n#[cfg(test)]").map_or(src, |(a, _)| a); - let spawn = prod - .find("tokio::process::Command::new(&bin)") - .expect("run_meridian's Command builder moved or was renamed"); - let output_call = prod[spawn..] - .find(".output();") - .expect("run_meridian's Command builder no longer ends in .output()"); - let builder = &prod[spawn..spawn + output_call]; - assert!( - builder.contains(".kill_on_drop(true)"), - "run_meridian's spawned child is missing .kill_on_drop(true) — a \ - timeout will orphan it instead of killing it. Builder was: {builder}" - ); - } - - /// A process that runs far longer than the timeout below, so the timeout - /// always wins the race — the exact shape `run_meridian` puts its child - /// in. No dependency on `meridian`/`MERIDIAN_BIN`: `sleep`/`ping` are - /// present on every macOS and Windows runner this crate's tests run on - /// (see `.github/workflows/ci.yml`'s `windows-latest` + `macos-latest` - /// `cargo test --workspace` jobs). - #[cfg(unix)] - fn long_running_command() -> (&'static str, &'static [&'static str]) { - ("sleep", &["30"]) - } - #[cfg(windows)] - fn long_running_command() -> (&'static str, &'static [&'static str]) { - // `timeout.exe` refuses to run with stdin redirected (no console - // handle) — `ping` to loopback is the standard "sleep N seconds" - // substitute on Windows and needs no real network. - ("ping", &["-n", "30", "127.0.0.1"]) - } - - #[cfg(unix)] - fn process_is_alive(pid: u32) -> bool { - std::process::Command::new("ps") - .args(["-p", &pid.to_string()]) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - } - #[cfg(windows)] - fn process_is_alive(pid: u32) -> bool { - // `tasklist` exits 0 either way, printing "No tasks are running..." - // when nothing matches — the pid has to actually appear in the - // output, not just a success status. - std::process::Command::new("tasklist") - .args(["/FI", &format!("PID eq {pid}"), "/NH"]) - .output() - .map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string())) - .unwrap_or(false) - } - - /// Proves the MECHANISM `run_meridian_kills_the_child_on_timeout` pins the - /// wiring for: that `kill_on_drop(true)` on a `tokio::process::Command` - /// actually terminates the child once the future racing it is dropped — - /// i.e. that this fix does what its own reasoning claims, not just that - /// the flag is textually present. - #[tokio::test] - async fn kill_on_drop_actually_terminates_the_orphaned_child() { - let (bin, args) = long_running_command(); - let child = tokio::process::Command::new(bin) - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true) - .spawn() - .expect("failed to spawn the long-running test process"); - let pid = child.id().expect("a just-spawned child must have a pid"); - assert!( - process_is_alive(pid), - "test bug: process not observed alive right after spawn" - ); - - { - // Mirrors `run_meridian` exactly: race the child's `.output()` - // against a timeout far shorter than the process's own runtime. - let output_fut = child.wait_with_output(); - let result = tokio::time::timeout(Duration::from_millis(50), output_fut).await; - assert!( - result.is_err(), - "test bug: the process exited before the timeout could fire" - ); - // `output_fut` (and the `Child` it consumed) drops here — exactly - // what happens when `tokio::time::timeout` drops `run_meridian`'s - // `.output()` future on a real timeout. - } - - // `kill_on_drop`'s kill is fired from `Drop`, not awaited to - // completion — poll briefly rather than asserting instantaneously. - let mut still_alive = process_is_alive(pid); - for _ in 0..20 { - if !still_alive { - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - still_alive = process_is_alive(pid); - } - assert!( - !still_alive, - "child (pid {pid}) is still running ~2s after being dropped — \ - kill_on_drop did not terminate it" - ); - } -} +#[path = "cli_exec_tests.rs"] +mod tests; diff --git a/tray/src-tauri/src/commands/cli_exec_tests.rs b/tray/src-tauri/src/commands/cli_exec_tests.rs new file mode 100644 index 000000000..ff2f85a16 --- /dev/null +++ b/tray/src-tauri/src/commands/cli_exec_tests.rs @@ -0,0 +1,274 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +//! Tests for [`super`] - split out to keep `cli_exec.rs` under the 500-line +//! rule. Attached with `#[path]` rather than a `cli_exec/` directory so the +//! `include_str!("cli_exec.rs")` source scan below keeps resolving against the +//! same directory. +use super::{log_non_zero_exit, NonZeroExit}; +use std::process::Stdio; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tracing_subscriber::{layer::SubscriberExt, registry, Layer}; + +/// One captured tracing event: level, message body, and `(field, value)` +/// pairs. Mirrors `commands::setup`'s recorder, which exists for the same +/// reason - `telemetry_spool::redact` filters on the EXACT field name, so a +/// test that only asserted "the stderr is logged somewhere" would pass +/// whether it landed in the body (always ships) or an unallowlisted +/// attribute (never ships), which is the entire distinction under test. +#[derive(Debug)] +struct CapturedEvent { + message: String, + fields: Vec<(String, String)>, +} + +struct Recorder(Arc>>); + +impl Layer for Recorder { + fn on_event(&self, event: &tracing::Event<'_>, _c: tracing_subscriber::layer::Context<'_, S>) { + struct V(Vec<(String, String)>); + impl tracing::field::Visit for V { + fn record_debug(&mut self, f: &tracing::field::Field, v: &dyn std::fmt::Debug) { + self.0.push((f.name().to_string(), format!("{v:?}"))); + } + } + let mut v = V(Vec::new()); + event.record(&mut v); + // `tracing` records the formatted message body under the reserved + // field name `message`; everything else is a structured attribute. + let message = + v.0.iter() + .find(|(k, _)| k == "message") + .map(|(_, val)| val.clone()) + .unwrap_or_default(); + let fields = v.0.into_iter().filter(|(k, _)| k != "message").collect(); + self.0 + .lock() + .unwrap() + .push(CapturedEvent { message, fields }); + } +} + +/// Run `f` under a bare recording subscriber (no `EnvFilter`, so level is +/// irrelevant) and return what it emitted. Drains through the `Mutex` +/// rather than `Arc::try_unwrap` - see `commands::setup::capture`'s note on +/// the Windows CI flake that caused. +fn capture(f: impl FnOnce()) -> Vec { + let seen = Arc::new(Mutex::new(Vec::new())); + let subscriber = registry().with(Recorder(Arc::clone(&seen))); + tracing::subscriber::with_default(subscriber, f); + let events = std::mem::take(&mut *seen.lock().unwrap()); + events +} + +/// The measured leak from issue #872, verbatim: a real user's ticket key +/// reached central OpenObserve inside a WARN body, because the tray spliced +/// the `meridian` subprocess's stderr into the message. `task_key` is denied +/// as an attribute and pinned so by +/// `redact::user_scoped_diagnostic_keys_stay_off_the_allowlist` - but the +/// body is not an attribute, so the allowlist never saw it. +const MEASURED_STDERR: &str = "Jira GET transitions for ENG-7041 returned 404 Not Found: {\"errorMessages\":[\"Issue does not exist or you do not have permission to see it.\"]}"; + +/// The regression this whole change exists for. +/// +/// Asserts BOTH halves, because either one alone is satisfiable by a bad +/// fix: the ticket key must not be in the body (or it ships), AND the +/// stderr must still be captured under `stderr_tail` (or we have repeated +/// #867 in the opposite direction and deleted the only record the failure +/// leaves on the engineer's own machine). +#[test] +fn a_non_zero_exit_keeps_subprocess_stderr_out_of_the_message_body() { + let events = capture(|| { + log_non_zero_exit(NonZeroExit { + label: "ticket-statuses", + bin: Some("/Users/someone/.meridian/bin/meridian"), + provider: Some("jira"), + key: Some("ENG-7041"), + code: Some(1), + stderr: MEASURED_STDERR, + }) + }); + let e = events.first().expect("log_non_zero_exit emitted no event"); + + assert!( + !e.message.contains("ENG-7041"), + "the user's ticket key is in the log BODY, which always ships - \ + redact's attribute allowlist cannot reach it. Body was: {}", + e.message + ); + assert!( + !e.message.contains("Issue does not exist"), + "the provider's error payload is in the log BODY. Body was: {}", + e.message + ); + assert!( + e.message.contains("ticket-statuses"), + "the body must still name WHICH subcommand failed. Body was: {}", + e.message + ); + + let tail = e + .fields + .iter() + .find(|(k, _)| k == "stderr_tail") + .map(|(_, v)| v.as_str()) + .expect( + "stderr is no longer captured at all - it must move to an \ + unallowlisted ATTRIBUTE, not disappear", + ); + assert!( + tail.contains("ENG-7041"), + "stderr_tail lost the diagnostic it exists to carry: {tail}" + ); +} + +/// `stderr_tail` must stay OFF `redact::SAFE_STRING_KEYS`, or moving the +/// stderr out of the body accomplishes nothing - it would ship under its new +/// name instead. Pinning it here rather than trusting the reader to +/// remember: the fix above is only a fix while this holds. +#[test] +fn the_key_the_stderr_moved_to_is_not_allowlisted_for_egress() { + let redact = include_str!("../../../../src/telemetry_spool/redact.rs"); + let allowlist = redact + .split_once("const SAFE_STRING_KEYS") + .expect("SAFE_STRING_KEYS was renamed - this guard no longer reads it") + .1 + .split_once("\n];") + .expect("SAFE_STRING_KEYS list terminator moved") + .0; + for key in ["stderr_tail", "key", "bin"] { + assert!( + !allowlist.contains(&format!("\"{key}\"")), + "`{key}` was added to redact::SAFE_STRING_KEYS, so the stderr / \ + ticket key / home-directory path this module deliberately keeps \ + local now egresses. See log_non_zero_exit's doc (#872)." + ); + } +} + +/// Regression guard: on a timeout, `tokio::time::timeout` drops the +/// `.output()` future, and without `kill_on_drop(true)` the spawned +/// `meridian ` keeps running in the background after the caller has +/// already reported failure to the user. For an LLM-backed call like +/// `plan-task-draft`, that orphan then competes with the next "Try again" +/// click's fresh process for the same provider/DB — a plausible reason a +/// draft that missed its 150s budget once keeps missing it on retry. +/// +/// This can't drive `run_meridian` itself as a real spawn-and-verify test: +/// it resolves its binary via `crate::install::meridian_bin()`, and +/// overriding that via `MERIDIAN_BIN` would mean `std::env::set_var` on a +/// shared test binary — exactly what `integrations.rs`'s "avoiding +/// `std::env::set_var` on a Tokio worker thread" note warns off. So this +/// is source-scanned, mirroring `tasks.rs::sync_tasks`, the sibling call +/// site that already carries this fix. The MECHANISM itself — that +/// `kill_on_drop(true)` actually terminates an orphaned child, on this +/// platform, with tokio's real process reaping — is verified separately +/// below, against a plain `tokio::process::Command` that needs no +/// `MERIDIAN_BIN` override at all. +#[test] +fn run_meridian_kills_the_child_on_timeout() { + let src = include_str!("cli_exec.rs"); + let prod = src.split_once("\n#[cfg(test)]").map_or(src, |(a, _)| a); + let spawn = prod + .find("tokio::process::Command::new(&bin)") + .expect("run_meridian's Command builder moved or was renamed"); + let output_call = prod[spawn..] + .find(".output();") + .expect("run_meridian's Command builder no longer ends in .output()"); + let builder = &prod[spawn..spawn + output_call]; + assert!( + builder.contains(".kill_on_drop(true)"), + "run_meridian's spawned child is missing .kill_on_drop(true) — a \ + timeout will orphan it instead of killing it. Builder was: {builder}" + ); +} + +/// A process that runs far longer than the timeout below, so the timeout +/// always wins the race — the exact shape `run_meridian` puts its child +/// in. No dependency on `meridian`/`MERIDIAN_BIN`: `sleep`/`ping` are +/// present on every macOS and Windows runner this crate's tests run on +/// (see `.github/workflows/ci.yml`'s `windows-latest` + `macos-latest` +/// `cargo test --workspace` jobs). +#[cfg(unix)] +fn long_running_command() -> (&'static str, &'static [&'static str]) { + ("sleep", &["30"]) +} +#[cfg(windows)] +fn long_running_command() -> (&'static str, &'static [&'static str]) { + // `timeout.exe` refuses to run with stdin redirected (no console + // handle) — `ping` to loopback is the standard "sleep N seconds" + // substitute on Windows and needs no real network. + ("ping", &["-n", "30", "127.0.0.1"]) +} + +#[cfg(unix)] +fn process_is_alive(pid: u32) -> bool { + std::process::Command::new("ps") + .args(["-p", &pid.to_string()]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} +#[cfg(windows)] +fn process_is_alive(pid: u32) -> bool { + // `tasklist` exits 0 either way, printing "No tasks are running..." + // when nothing matches — the pid has to actually appear in the + // output, not just a success status. + std::process::Command::new("tasklist") + .args(["/FI", &format!("PID eq {pid}"), "/NH"]) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string())) + .unwrap_or(false) +} + +/// Proves the MECHANISM `run_meridian_kills_the_child_on_timeout` pins the +/// wiring for: that `kill_on_drop(true)` on a `tokio::process::Command` +/// actually terminates the child once the future racing it is dropped — +/// i.e. that this fix does what its own reasoning claims, not just that +/// the flag is textually present. +#[tokio::test] +async fn kill_on_drop_actually_terminates_the_orphaned_child() { + let (bin, args) = long_running_command(); + let child = tokio::process::Command::new(bin) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .expect("failed to spawn the long-running test process"); + let pid = child.id().expect("a just-spawned child must have a pid"); + assert!( + process_is_alive(pid), + "test bug: process not observed alive right after spawn" + ); + + { + // Mirrors `run_meridian` exactly: race the child's `.output()` + // against a timeout far shorter than the process's own runtime. + let output_fut = child.wait_with_output(); + let result = tokio::time::timeout(Duration::from_millis(50), output_fut).await; + assert!( + result.is_err(), + "test bug: the process exited before the timeout could fire" + ); + // `output_fut` (and the `Child` it consumed) drops here — exactly + // what happens when `tokio::time::timeout` drops `run_meridian`'s + // `.output()` future on a real timeout. + } + + // `kill_on_drop`'s kill is fired from `Drop`, not awaited to + // completion — poll briefly rather than asserting instantaneously. + let mut still_alive = process_is_alive(pid); + for _ in 0..20 { + if !still_alive { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + still_alive = process_is_alive(pid); + } + assert!( + !still_alive, + "child (pid {pid}) is still running ~2s after being dropped — \ + kill_on_drop did not terminate it" + ); +} diff --git a/tray/src-tauri/src/commands/parents.rs b/tray/src-tauri/src/commands/parents.rs index cc5073781..a6f2a59b9 100644 --- a/tray/src-tauri/src/commands/parents.rs +++ b/tray/src-tauri/src/commands/parents.rs @@ -102,9 +102,19 @@ pub async fn get_ticket_parents(provider: String, key: String) -> ParentsRespons let msg = if stderr.is_empty() { format!("exited {:?}", output.status.code()) } else { - stderr + stderr.clone() }; - tracing::warn!(%provider, %key, "ticket-parents non-zero: {msg}"); + // Static body + `stderr_tail`, NOT `"…non-zero: {msg}"`. The stderr here + // is a tracker's error payload and routinely contains the ticket key - + // see `cli_exec::log_non_zero_exit` and issue #872. + crate::commands::cli_exec::log_non_zero_exit(crate::commands::cli_exec::NonZeroExit { + label: "ticket-parents", + bin: Some(bin.as_str()), + provider: Some(&provider), + key: Some(&key), + code: output.status.code(), + stderr: &stderr, + }); return ParentsResponse::failure(msg); } diff --git a/tray/src-tauri/src/commands/triage.rs b/tray/src-tauri/src/commands/triage.rs index 21941d880..c9a0aa145 100644 --- a/tray/src-tauri/src/commands/triage.rs +++ b/tray/src-tauri/src/commands/triage.rs @@ -260,9 +260,17 @@ pub async fn apply_ticket_fix(body: ApplyBody) -> Result let msg = if stderr.is_empty() { format!("ticket-update exited {:?}", output.status.code()) } else { - stderr + stderr.clone() }; - tracing::warn!("ticket-update non-zero: {msg}"); + // Static body + `stderr_tail` - see `cli_exec::log_non_zero_exit` (#872). + crate::commands::cli_exec::log_non_zero_exit(crate::commands::cli_exec::NonZeroExit { + label: "ticket-update", + bin: Some(bin.as_str()), + provider: Some(&body.provider), + key: Some(&body.key), + code: output.status.code(), + stderr: &stderr, + }); return Err(msg); } diff --git a/tray/src-tauri/src/lib.rs b/tray/src-tauri/src/lib.rs index 830fdc9a2..eb5105227 100644 --- a/tray/src-tauri/src/lib.rs +++ b/tray/src-tauri/src/lib.rs @@ -133,8 +133,14 @@ fn catch_setup_panic(what: &str, f: impl FnOnce() -> T + std::panic::UnwindSa .map(|s| s.to_string()) .or_else(|| payload.downcast_ref::().cloned()) .unwrap_or_else(|| "non-string panic payload".to_string()); + // `panic_msg`, not `msg`: `errors.rs::no_user_data_interpolated_into_a_log_body` + // allows exactly three identifiers into a WARN+ body and this is one of + // them, named so the exemption is legible at the call site. A panic + // payload is our own `expect`/`panic!` text, not a subprocess's or a + // provider's output - the distinction that guard exists to force. + let panic_msg = msg; tracing::error!( - "tray setup panicked during {what}: {msg} — degrading to a disabled state instead of crashing the tray" + "tray setup panicked during {what}: {panic_msg} — degrading to a disabled state instead of crashing the tray" ); None } From e5dc92fa272f0330b363f58ede0e2d8e32578036 Mon Sep 17 00:00:00 2001 From: Akarsh Hegde Date: Thu, 27 Aug 2026 20:44:40 +0530 Subject: [PATCH 25/53] fix(telemetry): close three coverage holes in the log-body lint Found by reviewing the guard against the claim privacy.md now makes for it. 1. The scan truncated each file at its first `#[cfg(test)]`, the idiom inherited from `no_bare_error_display_in_db_paths`. On a whole-tree scan that is silently catastrophic: everything after the first test module is discarded, including the real code below it. Measured - tray/src-tauri/src/lib.rs was scanned at 6.6% of its bytes, and jira/mod.rs at 2%, because a bare `#[cfg(test)] mod tests;` DECLARATION on line 15 truncated the file. Replaced with brace-balanced `strip_test_modules`; reachable macro sites 493 -> 526. 2. `offenders.is_empty()` passes when the scan finds NOTHING, so a parse regression would switch the guard off without failing it - exactly how hole 1 stayed invisible. MIN_MACRO_SITES pins the parse the way the file-count assert already pinned the walk, counted in the same loop so the two cannot drift. 3. privacy.md said "a build fails if any warning or error message formats a value into its text", but positional `{}` was out of scope, so `warn!("failed for {}", ticket_key)` passed. Deferring it to `no_bare_error_display_in_db_paths` would not have worked - that scan's own doc scopes it to five DB files. There are zero positional WARN+ bodies in the tree, so it is enforced instead and the sentence is now true. Also: `provider` is allowlisted and ships, but arrives at the tray as an unvalidated String from the frontend, so the allowlist's "fixed internal set" premise rested on the frontend behaving. `provider_label` narrows it to the six tracker literals or "other", the same shape as `install::bin_source`. The SAFE_STRING_KEYS comment described only the LLM-engine domain while parents.rs was already emitting tracker names - now names both. Co-Authored-By: Claude Opus 5 (1M context) --- src/log_hygiene.rs | 195 ++++++++++++++++-- src/telemetry_spool/redact.rs | 16 +- tray/src-tauri/src/commands/cli_exec.rs | 29 ++- tray/src-tauri/src/commands/cli_exec_tests.rs | 49 +++++ 4 files changed, 266 insertions(+), 23 deletions(-) diff --git a/src/log_hygiene.rs b/src/log_hygiene.rs index bbf9ec8b9..cb8636ca1 100644 --- a/src/log_hygiene.rs +++ b/src/log_hygiene.rs @@ -113,11 +113,7 @@ mod tests { }) { if let Ok(src) = std::fs::read_to_string(&p) { - let prod = src - .split_once("\n#[cfg(test)]") - .map_or(src.as_str(), |(a, _)| a) - .to_string(); - out.push((p.display().to_string(), prod)); + out.push((p.display().to_string(), strip_test_modules(&src))); } } } @@ -139,10 +135,97 @@ mod tests { out } + /// Remove `#[cfg(test)] mod … { … }` BLOCKS, brace-balanced, and nothing + /// else. + /// + /// # Why not truncate at the first `#[cfg(test)]` + /// That is the older idiom (`errors.rs::no_bare_error_display_in_db_paths` + /// still uses it) and it is silently catastrophic for a whole-tree scan: + /// everything after the FIRST test module is discarded, including the real + /// code below it. Measured on this tree before the change - + /// `tray/src-tauri/src/lib.rs` was scanned at 6.6% of its bytes (a test + /// module sits at line 150 of 1915), and `intelligence/providers/jira/mod.rs` + /// at 2%, because a bare `#[cfg(test)] mod tests;` DECLARATION on line 15 + /// truncated the file. A declaration points at a separate file, which the + /// walk already skips by name, so it must not truncate anything at all. + /// + /// The scan would still have passed - just over a fraction of the codebase. + /// That is the failure mode `SCANNED_TREES`'s file-count assert and + /// `MIN_MACRO_SITES` below both exist to make loud. + fn strip_test_modules(src: &str) -> String { + const MARKER: &str = "#[cfg(test)]"; + let mut out = String::with_capacity(src.len()); + let mut rest = src; + while let Some(i) = rest.find(MARKER) { + let after = &rest[i + MARKER.len()..]; + // `#[cfg(test)] mod name { … }` - only a BLOCK is stripped. A `;` + // declaration, or the attribute on a fn/const/use, is kept. + let Some(brace) = after.find('{') else { + out.push_str(&rest[..i + MARKER.len()]); + rest = after; + continue; + }; + let head = &after[..brace]; + if !head.trim_start().starts_with("mod ") || head.contains(';') { + out.push_str(&rest[..i + MARKER.len()]); + rest = after; + continue; + } + out.push_str(&rest[..i]); + // Balance braces from the module's opening one, ignoring string + // literals and line comments (both can contain a stray brace). + let body: Vec = after[brace..].chars().collect(); + let (mut depth, mut k) = (0usize, 0usize); + let (mut in_str, mut esc, mut in_comment) = (false, false, false); + while k < body.len() { + let c = body[k]; + if in_comment { + if c == '\n' { + in_comment = false; + } + } else if in_str { + if esc { + esc = false; + } else if c == '\\' { + esc = true; + } else if c == '"' { + in_str = false; + } + } else if c == '"' { + in_str = true; + } else if c == '/' && body.get(k + 1) == Some(&'/') { + in_comment = true; + } else if c == '{' { + depth += 1; + } else if c == '}' { + depth -= 1; + if depth == 0 { + k += 1; + break; + } + } + k += 1; + } + let consumed: usize = body[..k].iter().map(|c| c.len_utf8()).sum(); + rest = &after[brace + consumed..]; + } + out.push_str(rest); + out + } + /// Every `{ident}` interpolated into the message body of a `tracing::warn!` /// or `tracing::error!` in `src`, with its line number. fn interpolated_body_idents(src: &str) -> Vec<(usize, String, String)> { + scan_bodies(src).1 + } + + /// [`interpolated_body_idents`] plus the number of WARN+/ERROR macro sites + /// the parser REACHED. The count comes out of the same loop on purpose - a + /// separate counting fn could drift from the real scan, and then + /// `MIN_MACRO_SITES` would be measuring something the guard does not use. + fn scan_bodies(src: &str) -> (usize, Vec<(usize, String, String)>) { let mut found = Vec::new(); + let mut reached = 0usize; // Matched WITHOUT the `tracing::` prefix on purpose: 14 sites in // `etl/` and `capture/` import the macro (`use tracing::warn`) and call // it bare, and a scan anchored on the qualified spelling would have read @@ -168,6 +251,7 @@ mod tests { let Some(open) = src[start..].find('(').map(|i| start + i) else { continue; }; + reached += 1; // Balance parens, ignoring anything inside a string literal. let (mut depth, mut i) = (0usize, open); let (mut in_str, mut esc) = (false, false); @@ -208,11 +292,21 @@ mod tests { } } } - found + (reached, found) } /// The last `"…"` literal in a macro argument list, unescaped enough to read - /// its `{}` placeholders. + /// its `{}` placeholders - `tracing`'s own rule for which literal is the + /// message. + /// + /// # Residual, stated + /// A message followed by a string-literal ARGUMENT + /// (`warn!("dropped {n} for {}", "unknown")`) resolves to the argument, so + /// that site's real message goes unscanned. No such site exists in the tree + /// today (473 of 493 macro sites parse a message; the other 20 carry no + /// literal at all). Scanning every literal instead would false-positive on + /// a non-message field value, which is the worse trade for a shape that + /// does not occur. fn last_string_literal(args: &str) -> Option { let chars: Vec = args.chars().collect(); let (mut i, mut last) = (0usize, None); @@ -240,10 +334,20 @@ mod tests { last } - /// Named `{ident}` captures in a format string. Positional `{}` / `{:?}` are - /// out of scope: they take their value from a trailing argument, and - /// `no_bare_error_display_in_db_paths` already covers the `%e` case that - /// produces in practice. + /// What a positional `{}` / `{:?}` is reported as. It has no name to print, + /// but it is exactly as dangerous - `warn!("failed for {}", ticket_key)` + /// ships the key just as surely as `"failed for {ticket_key}"` does. + /// + /// It is enforced rather than deferred because there are currently ZERO of + /// them in a WARN+/ERROR body across all four trees, so the rule costs + /// nothing to hold and `docs/privacy.md` can state it to users without + /// qualification. Deferring it to `no_bare_error_display_in_db_paths` (the + /// earlier plan) would not have worked: that scan's own doc scopes it to + /// five DB files and explicitly leaves ~120 sites elsewhere uncovered. + const POSITIONAL: &str = ""; + + /// Every placeholder in a format string: named `{ident}` captures by name, + /// and positional `{}` / `{:?}` as [`POSITIONAL`]. fn placeholders(msg: &str) -> Vec { let mut out = Vec::new(); let chars: Vec = msg.chars().collect(); @@ -260,12 +364,15 @@ mod tests { name.push(chars[j]); j += 1; } - // Only a NAMED capture: `{x}` or `{x:?}`, not `{}` or `{:?}`. - if !name.is_empty() - && !name.starts_with(|c: char| c.is_ascii_digit()) - && matches!(chars.get(j), Some('}') | Some(':')) - { + let closes = matches!(chars.get(j), Some('}') | Some(':')); + if !name.is_empty() && !name.starts_with(|c: char| c.is_ascii_digit()) && closes { + // A NAMED capture: `{x}` or `{x:?}`. out.push(name); + } else if closes { + // `{}`, `{:?}`, `{0}` - the value comes from a trailing + // argument, which the scan cannot see. Reported all the + // same; see `POSITIONAL`. + out.push(POSITIONAL.to_string()); } i = j; } else { @@ -279,11 +386,28 @@ mod tests { /// allowlist cannot reach - so nothing runtime-valued may be formatted into /// it. See [`INTERPOLABLE`] for the full reasoning and issue #872 for the /// leak that prompted this. + /// A floor on how many WARN+/ERROR macro sites the parser actually REACHES. + /// + /// `offenders.is_empty()` passes when the scan finds nothing, so a + /// regression in the paren balancing, the literal extraction, or + /// [`strip_test_modules`] would turn this guard off without failing it - + /// which is precisely how the truncation bug documented on + /// `strip_test_modules` hid a 94% coverage loss on one file. The file-count + /// assert in [`production_sources`] guards the walk; this guards the parse. + /// + /// Set well below the real count - 526 today, up from 493 before + /// [`strip_test_modules`] replaced the truncation - so ordinary deletions + /// don't trip it. Raise it if it ever does; do not lower it. + const MIN_MACRO_SITES: usize = 400; + #[test] fn no_user_data_interpolated_into_a_log_body() { let mut offenders = Vec::new(); + let mut reached = 0usize; for (path, src) in production_sources() { - for (line, ident, msg) in interpolated_body_idents(&src) { + let (sites, hits) = scan_bodies(&src); + reached += sites; + for (line, ident, msg) in hits { if !INTERPOLABLE.contains(&ident.as_str()) { offenders.push(format!("{path}:{line} - `{{{ident}}}` in \"{msg}\"")); } @@ -302,6 +426,13 @@ mod tests { a compile-time literal at every call site, add it to `INTERPOLABLE` \ with a justification. Offenders: {offenders:#?}" ); + assert!( + reached >= MIN_MACRO_SITES, + "the scan reached only {reached} WARN+/ERROR macro sites, below the \ + {MIN_MACRO_SITES} floor - the parse or the test-module stripping has \ + regressed and this guard is now passing over a fraction of the \ + codebase. See MIN_MACRO_SITES." + ); } /// The scan above is only worth having if it actually catches the #872 @@ -345,13 +476,39 @@ mod tests { interpolated_body_idents(r#"// tracing::warn!("{msg}");"#).is_empty(), "a commented-out example was flagged" ); + + // `strip_test_modules` must remove a test module's BODY and nothing + // else. The `mod tests;` case is the one that silently truncated 98% of + // `jira/mod.rs` under the old `split_once` idiom. + let with_decl = "#[cfg(test)]\nmod tests;\nfn f() { warn!(\"x {y}\"); }"; + assert!( + !interpolated_body_idents(&strip_test_modules(with_decl)).is_empty(), + "a `#[cfg(test)] mod tests;` DECLARATION truncated the rest of the file" + ); + let with_block = "fn f() { warn!(\"a {p}\"); }\n#[cfg(test)]\nmod t {\n warn!(\"b {q}\");\n}\nfn g() { warn!(\"c {r}\"); }"; + let idents: Vec = interpolated_body_idents(&strip_test_modules(with_block)) + .into_iter() + .map(|(_, i, _)| i) + .collect(); + assert!( + idents.contains(&"p".to_string()) && idents.contains(&"r".to_string()), + "code around a test module was stripped with it: {idents:?}" + ); + assert!( + !idents.contains(&"q".to_string()), + "the test module's own body was scanned: {idents:?}" + ); assert!( interpolated_body_idents(r#"fn my_error!(x) { "{msg}" }"#).is_empty(), "`my_error!` is a different macro and must not be scanned" ); + // A positional placeholder is caught too - it has no name, but + // `warn!("failed for {}", ticket_key)` ships the key all the same. assert!( - interpolated_body_idents(r#"tracing::warn!(n = 1, "dropped {} rows", n);"#).is_empty(), - "a positional `{{}}` placeholder is out of scope - see `placeholders`" + interpolated_body_idents(r#"tracing::warn!(n = 1, "dropped {} rows", n);"#) + .iter() + .any(|(_, i, _)| i == POSITIONAL), + "a positional `{{}}` placeholder was not flagged - see `POSITIONAL`" ); assert!( interpolated_body_idents(r#"tracing::warn!("100{{msg}} percent");"#).is_empty(), diff --git a/src/telemetry_spool/redact.rs b/src/telemetry_spool/redact.rs index d3b81b92e..ddbfec654 100644 --- a/src/telemetry_spool/redact.rs +++ b/src/telemetry_spool/redact.rs @@ -125,9 +125,19 @@ const SAFE_STRING_KEYS: &[&str] = &[ // output, so it needs the full scrub, not just a path scrub. "error", // Which provider/engine a failure came from. Enum-like values from a fixed - // internal set (`claude`, `codex`, `anthropic`, `gemini`, …) — they name - // OUR components, never the user's data, and without them an LLM or - // summariser failure can't be attributed to a backend at all. + // internal set — they name OUR components, never the user's data, and + // without them an LLM or summariser failure can't be attributed to a + // backend at all. + // + // TWO value domains share this key, and the second is easy to miss: LLM + // engines (`claude`, `codex`, `anthropic`, `gemini`, …) AND task trackers + // (`jira`, `linear`, `github`, `azure_devops`, `asana`, `trello`), from the + // tray's `ticket-parents`/`ticket-update` shell-outs. That second domain + // reaches the tray as a plain `String` from the frontend, so + // `commands::cli_exec::provider_label` narrows it to those literals or + // `"other"` before it is logged. An allowlist entry whose comment does not + // describe all of its emitters is the exact shape of #872 - do not add a + // third domain here without naming it. "provider", "engine", // ── health checks (`crate::health::Report::log`) ───────────────────────── diff --git a/tray/src-tauri/src/commands/cli_exec.rs b/tray/src-tauri/src/commands/cli_exec.rs index 6b47eb560..b48ef2f48 100644 --- a/tray/src-tauri/src/commands/cli_exec.rs +++ b/tray/src-tauri/src/commands/cli_exec.rs @@ -170,6 +170,33 @@ pub(crate) struct NonZeroExit<'a> { /// from every shipped record. An `IntValue` is kept unconditionally for any key /// ([`meridian::telemetry_spool::redact`]'s first `keep_attribute` arm), so this /// change also *restores* a diagnostic the previous form silently lost. +/// Map a tracker name onto a fixed set of literals, or `"other"`. +/// +/// `provider` is on `redact::SAFE_STRING_KEYS` and therefore SHIPS, justified +/// there as "enum-like values from a fixed internal set". At this call site the +/// value arrives from the frontend as a plain `String` (`get_ticket_parents`'s +/// and `apply_ticket_fix`'s parameters) and nothing between there and here +/// validates it - so the allowlist's premise would have rested on the frontend +/// behaving, which is not a property anyone can audit. Normalising here makes it +/// structurally true instead, the same way `install::bin_source` can only ever +/// return one of five literals. +/// +/// Mirrors `meridian_core::canonical_task::Provider::as_str`. A name that falls +/// through to `"other"` still says a tracker call failed, which is the whole +/// diagnostic value; it just cannot smuggle a typed string onto the wire. +fn provider_label(p: &str) -> &'static str { + match p { + "jira" => "jira", + "linear" => "linear", + "github" => "github", + "azure_devops" => "azure_devops", + "asana" => "asana", + "trello" => "trello", + "" => "", + _ => "other", + } +} + pub(crate) fn log_non_zero_exit(ctx: NonZeroExit<'_>) { let NonZeroExit { label, @@ -182,7 +209,7 @@ pub(crate) fn log_non_zero_exit(ctx: NonZeroExit<'_>) { tracing::warn!( bin = bin.unwrap_or(""), bin_source = bin.map(crate::install::bin_source).unwrap_or(""), - provider = provider.unwrap_or(""), + provider = provider.map(provider_label).unwrap_or(""), key = key.unwrap_or(""), // `-1` for "signalled, no exit code" - `Option` would stringify. code = code.unwrap_or(-1) as i64, diff --git a/tray/src-tauri/src/commands/cli_exec_tests.rs b/tray/src-tauri/src/commands/cli_exec_tests.rs index ff2f85a16..53d39da22 100644 --- a/tray/src-tauri/src/commands/cli_exec_tests.rs +++ b/tray/src-tauri/src/commands/cli_exec_tests.rs @@ -121,6 +121,55 @@ fn a_non_zero_exit_keeps_subprocess_stderr_out_of_the_message_body() { ); } +/// `provider` SHIPS, so an unrecognised value must not reach the wire verbatim. +/// It arrives here from the frontend as a plain `String` with nothing +/// validating it - see `provider_label`. +#[test] +fn an_unrecognised_provider_is_narrowed_before_it_ships() { + fn shipped_provider(p: &str) -> String { + let events = capture(|| { + log_non_zero_exit(NonZeroExit { + label: "ticket-update", + bin: None, + provider: Some(p), + key: None, + code: Some(1), + stderr: "boom", + }) + }); + events[0] + .fields + .iter() + .find(|(k, _)| k == "provider") + .map(|(_, v)| v.clone()) + .expect("provider field missing") + } + + let shipped = shipped_provider("acme-internal-tracker-prod"); + assert!( + !shipped.contains("acme-internal-tracker-prod"), + "an unvalidated provider name reached an ALLOWLISTED field: {shipped}" + ); + assert!(shipped.contains("other"), "{shipped}"); + + // …while every real tracker still reports itself. + for p in [ + "jira", + "linear", + "github", + "azure_devops", + "asana", + "trello", + ] { + let shipped = shipped_provider(p); + assert!( + shipped.contains(p), + "`{p}` was narrowed away - provider_label has drifted from \ + meridian_core::canonical_task::Provider::as_str: {shipped}" + ); + } +} + /// `stderr_tail` must stay OFF `redact::SAFE_STRING_KEYS`, or moving the /// stderr out of the body accomplishes nothing - it would ship under its new /// name instead. Pinning it here rather than trusting the reader to From 97284c30de54bb02752803d3797712673b0b5667 Mon Sep 17 00:00:00 2001 From: adityaharishch Date: Thu, 27 Aug 2026 21:42:33 +0530 Subject: [PATCH 26/53] fix(intelligence): sync PM tasks on demand instead of on a timer Production users kept hitting a permanent `refresh_token is invalid` Jira disconnect. The cause was the background sync timer, not the network. macOS dark-wakes a sleeping machine every 15-16 minutes. The daemon resumed, found the OAuth access token expired, and POSTed a refresh; Atlassian rotated the refresh token and replied; the machine re-suspended before the response was read. reqwest's timeout could not cancel it - the timeout is Instant-based and the monotonic clock does not advance while the system is asleep, so the request simply hung. On the next dark wake the socket was dead and the retry went out with the OLD refresh token, by then past Atlassian's 10-minute reuse leeway: `invalid_grant`, classified terminal, grant dead forever. The dark-wake interval is LONGER than the leeway, which is why no retry policy fixes this from inside the daemon. A sleeping machine has no work to do, so it must attempt no token refreshes. That is the whole change. Removed * The every-tick `run_pm_sync` in the daemon poll loop. * The one-time startup pass. launchd KeepAlive restarts the daemon on every wake, so sync-on-boot is a wake-triggered refresh in disguise. Added, all of them things a PRESENT user did * `meridian pm-sync` - a gated CLI sibling of `tasks-sync`. * Dashboard opened (popover button, notification click, tray menu) -> gated background sync. Not hung off the dashboard's own read commands: TasksPanel polls `get_tasks` every 60s and OverviewPanel `get_plan` every 30s, so attaching it there would have recreated the timer inside the tray's read path. * Tracker connected (API token, loopback OAuth, GitHub device flow) -> FORCED sync. Forced because `pm_sync_state` can read "synced 2 minutes ago" from a previous account while `pm_tasks` holds another board. * Before the worklog drafting sweep, and in `worklog-generate`. This is the one consumer where a stale board is harmful rather than cosmetic: it binds an hour of work to a ticket closed hours ago. Single source of truth, no duplicate fetches * Staleness is still decided in exactly one place per provider - `refresh_if_stale` reading `pm_sync_state.last_synced_at`. No second notion of freshness was introduced. * `intelligence::sync_lock`: an advisory FILE lock, plus an in-process mutex (flock is per open-file-description, so two tasks in one process would each be granted it). Gated callers skip on contention; forced callers wait, then proceed. Opening the dashboard while a drafting sweep starts is the ordinary case, and now costs one fetch, not two. Deliberately NOT a table, and this is the point The reverted `pm_sync_requests` outbox (#909/#910) coordinated the tray and the daemon through meridian.db, and the tray's read-back of the outcome is what failed in production with SQLITE_IOERR_SHORT_READ (522) - a short read while the daemon truncated the WAL on shutdown. Here the tray spawns `meridian pm-sync` and forgets it: no handshake, no outcome to read, no new table, no migration. `git status src/migrations/` is empty and the diff contains no DDL, so shipping this cannot damage a database or leave a request wedged. Health check `health::jira::sync_freshness` warned purely on elapsed time (>1h => "fetch may be failing silently"). That was a fair proxy only while a timer synced regardless; now a machine shut all weekend would warn every Monday. It reads `pm_sync_state.last_error` instead - a recorded failure warns with the provider's own message, elapsed time is context only. Tests * `the_daemon_poll_loop_never_syncs_pm_tasks` - source-level, because a reintroduced timer would work perfectly and only destroy grants months later. Mutation-checked: planting a call in the poll loop fails it. * `neither_sweep_is_gated_on_a_connected_tracker` now scans BOTH halves of the split `auto_generate` module. Splitting it for the 500-line cap would otherwise have silently halved the guard's reach; mutation-checked by planting the gate in sweep.rs alone. * sync_lock contention/timeout/uncontended, and four sync_freshness cases including the stale-but-healthy false positive. `auto_generate.rs` was 511 lines after the pre-draft sync landed, so it splits into mod.rs (the clock gate) + sweep.rs (what a sweep does). Not addressed here, deliberately: the refresh POST still has no wall-clock deadline, there is still no wake detection, and OAuth remains structurally vulnerable to a >10-minute suspend landing mid-refresh. Those are the hardening slice. Scoped API tokens are the only immune option and are a separate change again. --- src/health/jira.rs | 144 +++++++- src/intelligence/mod.rs | 115 +++++++ src/intelligence/sync_lock.rs | 238 ++++++++++++++ src/main.rs | 105 ++++-- .../mod.rs} | 280 +--------------- src/pm_worklog/auto_generate/sweep.rs | 308 ++++++++++++++++++ tray/src-tauri/src/commands/integrations.rs | 35 +- tray/src-tauri/src/commands/system.rs | 10 + tray/src-tauri/src/commands/tasks.rs | 185 ++++++++--- tray/src-tauri/src/tray.rs | 10 + .../timeline/settings/IntegrationsSection.tsx | 2 +- 11 files changed, 1090 insertions(+), 342 deletions(-) create mode 100644 src/intelligence/sync_lock.rs rename src/pm_worklog/{auto_generate.rs => auto_generate/mod.rs} (52%) create mode 100644 src/pm_worklog/auto_generate/sweep.rs diff --git a/src/health/jira.rs b/src/health/jira.rs index ac4ec38a6..fa35db6d5 100644 --- a/src/health/jira.rs +++ b/src/health/jira.rs @@ -12,9 +12,6 @@ use crate::intelligence::oauth::{jira as oauth_jira, store as oauth_store}; use sqlx::SqlitePool; use std::time::Duration; -/// Cache older than this (2× the 30-min sync interval) ⇒ fetch likely failing. -const SYNC_STALE_SECS: f64 = 3600.0; - pub async fn checks(_cfg: &Config, pool: Option<&SqlitePool>) -> Vec { let mut out = Vec::new(); @@ -132,28 +129,47 @@ async fn classify_auth(send: reqwest::Result) -> Check { } } +/// Report whether the last Jira sync FAILED, not whether it was long ago. +/// +/// This used to warn purely on elapsed time (`age > 3600s` => "fetch may be +/// failing silently"), which was a reasonable proxy only while a background +/// timer synced every few minutes no matter what. PM sync is on-demand now (see +/// [`crate::intelligence::run_pm_sync`]): a machine that was shut all weekend, +/// or a user who has not opened the dashboard since Friday, has a legitimately +/// old cache and nothing is wrong. Warning on that is a false alarm nobody can +/// act on - and a check that cries wolf during normal operation is worse than no +/// check, because it teaches people to ignore the one that matters. +/// +/// So the signal is the OUTCOME instead: `pm_sync_state.last_error`, written by +/// `providers::record_sync_failure` and cleared by `clear_sync_error`. A recorded +/// failure warns and carries the provider's own message; anything else reports +/// elapsed time as context only, never as a fault. async fn sync_freshness(pool: &SqlitePool) -> Check { - match sqlx::query_scalar::<_, Option>( - "SELECT (julianday('now') - julianday(MAX(last_synced_at))) * 86400.0 + match sqlx::query_as::<_, (Option, Option)>( + "SELECT (julianday('now') - julianday(last_synced_at)) * 86400.0, last_error FROM pm_sync_state WHERE provider = 'jira'", ) - .fetch_one(pool) + .fetch_optional(pool) .await { - Ok(Some(age)) if age > SYNC_STALE_SECS => Check::warn( - "ticket sync", - "L3", - format!( - "cache {:.0}m stale — fetch may be failing silently", - age / 60.0 - ), - ) - .with_remedy("check the auth row above; the daemon refreshes every 30m"), - Ok(Some(age)) => Check::ok( + // A recorded failure is the ONLY thing that warns. `last_error` is set by + // the provider itself, so the text is the real cause rather than this + // check's guess at one. + Ok(Some((_, Some(err)))) if !err.trim().is_empty() => { + Check::warn("ticket sync", "L3", format!("last sync failed: {err}")).with_remedy( + "check the auth row above; a sync retries on the next on-demand trigger", + ) + } + // Elapsed time as context. An old cache is normal when nothing asked for a sync. + Ok(Some((Some(age), _))) => Check::ok( "ticket sync", "L3", - format!("fresh ({:.0}m ago)", age / 60.0), + format!("last synced {:.0}m ago, no errors", age / 60.0), ), + // A row with neither a parseable timestamp nor an error: nothing to report. + Ok(Some((None, _))) => { + Check::info("ticket sync", "L3", "no successful Jira sync recorded yet") + } Ok(None) => Check::info("ticket sync", "L3", "no Jira sync recorded yet"), Err(e) => Check::warn( "ticket sync", @@ -188,3 +204,97 @@ async fn candidate_count(pool: &SqlitePool) -> Check { ), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::health::Severity; + use sqlx::sqlite::SqlitePoolOptions; + + async fn db() -> SqlitePool { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::migrate!("src/migrations").run(&pool).await.unwrap(); + pool + } + + /// Seed `pm_sync_state` with a sync `days_ago` in the past and an optional + /// recorded error. + async fn seed(pool: &SqlitePool, days_ago: f64, last_error: Option<&str>) { + sqlx::query( + "INSERT INTO pm_sync_state (provider, last_synced_at, last_error) + VALUES ('jira', strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ?), ?)", + ) + .bind(format!("-{days_ago} days")) + .bind(last_error) + .execute(pool) + .await + .unwrap(); + } + + /// THE REGRESSION THIS EXISTS FOR. A three-day-old cache with no recorded + /// failure is normal once syncing is on-demand - the machine was shut, or + /// nobody opened the dashboard. The old elapsed-time rule warned at one hour, + /// which would now fire on healthy installs every Monday morning. + #[tokio::test] + async fn a_long_quiet_period_with_no_error_is_not_a_fault() { + let pool = db().await; + seed(&pool, 3.0, None).await; + let check = sync_freshness(&pool).await; + assert_eq!( + check.severity, + Severity::Ok, + "a stale-but-successful sync must not warn, got {check:?}" + ); + } + + /// A recorded failure warns and carries the provider's own message, so the + /// user sees the real cause rather than this check's guess at one. + #[tokio::test] + async fn a_recorded_failure_warns_with_the_providers_own_message() { + let pool = db().await; + seed(&pool, 0.01, Some("refresh_token is invalid")).await; + let check = sync_freshness(&pool).await; + assert_eq!( + check.severity, + Severity::Warn, + "a recorded failure must warn" + ); + assert!( + check.detail.contains("refresh_token is invalid"), + "the provider's message must survive into the detail, got {:?}", + check.detail + ); + } + + /// An empty string is not a failure. `clear_sync_error` blanks the column on + /// success on some paths, and treating `''` as an error would leave a + /// permanent warn on a perfectly healthy install. + #[tokio::test] + async fn a_blank_last_error_is_not_a_failure() { + let pool = db().await; + seed(&pool, 0.5, Some(" ")).await; + let check = sync_freshness(&pool).await; + assert_eq!( + check.severity, + Severity::Ok, + "a blank last_error must not warn, got {check:?}" + ); + } + + /// No row at all - a tracker that has never synced. Informational, never a + /// fault: this is every fresh install for its first few minutes. + #[tokio::test] + async fn never_synced_is_informational() { + let pool = db().await; + let check = sync_freshness(&pool).await; + assert_eq!( + check.severity, + Severity::Info, + "never-synced must be info, got {check:?}" + ); + } +} diff --git a/src/intelligence/mod.rs b/src/intelligence/mod.rs index 269728fd4..c57130f24 100644 --- a/src/intelligence/mod.rs +++ b/src/intelligence/mod.rs @@ -3,6 +3,7 @@ pub mod oauth; pub mod providers; pub mod session_categorizer; +pub mod sync_lock; pub mod task_triage; pub mod ticket_update; @@ -11,6 +12,24 @@ use sqlx::SqlitePool; use crate::config::{Config, PmProviderConfig}; +/// Serialises PM sync WITHIN this process, above [`sync_lock`]'s cross-process +/// file lock. +/// +/// Both layers are needed and neither subsumes the other: `flock` is held per +/// open-file-description, so two tasks in ONE process would each open the lock +/// file and each be granted it. The daemon has two callers that can overlap (a +/// worklog drafting sweep and a forced sync from `ticket-update`), so without +/// this the file lock would not dedupe them at all. +/// +/// A `tokio::sync::Mutex` rather than a `std` one because it is held across the +/// provider loop's `.await`s. +static IN_PROCESS_SYNC: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +/// How long a FORCED sync waits for a gated one to finish before giving up on +/// the lock. Comfortably inside the tray's 30 s `tasks-sync` budget, so the +/// caller reports honestly instead of being killed mid-wait. +const FORCE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(20); + /// True once at least one PM task is cached. Rows only land in `pm_tasks` after a /// provider authenticated and fetched successfully, so a non-zero count is proof /// a tracker actually WORKS (not merely that keys are present — bad creds 401 and @@ -39,6 +58,31 @@ pub async fn run_pm_force_sync(meridian: &SqlitePool, config: &Config) -> Result if config.pm_providers.is_empty() { return Ok(()); } + let _in_process = IN_PROCESS_SYNC.lock().await; + // A forced sync must NOT skip on contention - somebody pressed "Sync now", + // or a tracker write just landed and the board is knowingly behind. So wait + // for the holder, and if the budget expires proceed anyway: a duplicate + // fetch is wasteful, whereas silently doing nothing after an explicit + // request is the "sync is still running" dead end users already hit. + // Lock evaluation failing is logged and ignored for the same reason - the + // dedup lock must never become a new way for a sync to fail. + let _cross_process = match sync_lock::acquire_waiting(FORCE_LOCK_WAIT).await { + Ok(Some(guard)) => Some(guard), + Ok(None) => { + tracing::info!( + waited_s = FORCE_LOCK_WAIT.as_secs(), + "force sync: another sync still holds the lock - proceeding anyway" + ); + None + } + Err(e) => { + tracing::warn!( + error = %crate::errors::chain(&e), + "force sync: could not evaluate the PM sync lock - proceeding without dedup" + ); + None + } + }; for provider in &config.pm_providers { let name = provider.provider_name(); let result = match provider { @@ -80,6 +124,35 @@ pub async fn run_pm_sync(meridian: &SqlitePool, config: &Config) -> Result<()> { tracing::warn!("no PM providers configured — pm_tasks will stay empty (set JIRA_BASE_URL/GITHUB_TOKEN/LINEAR_API_KEY/AZURE_DEVOPS_PAT)"); return Ok(()); } + let _in_process = IN_PROCESS_SYNC.lock().await; + // Gated callers SKIP when another sync is in flight: that run is already + // fetching the board this one wanted, so a second fetch is pure duplicate + // load on the tracker's API. This is the only thing standing between "the + // dashboard was opened while a drafting sweep started" and two identical + // board fetches. + let _cross_process = match sync_lock::try_acquire() { + Ok(Some(guard)) => guard, + Ok(None) => { + tracing::debug!("pm sync already in flight in another process - skipping"); + return Ok(()); + } + // Never fail a sync because the DEDUP lock was unreadable - degrading to + // "no dedup" is strictly better than degrading to "no sync". + Err(e) => { + tracing::warn!( + error = %crate::errors::chain(&e), + "could not evaluate the PM sync lock - proceeding without dedup" + ); + return run_pm_sync_providers(meridian, config).await; + } + }; + run_pm_sync_providers(meridian, config).await +} + +/// The provider loop itself, split from [`run_pm_sync`] so the locking policy +/// above has exactly one body to guard and cannot drift from it. +#[tracing::instrument(skip_all)] +async fn run_pm_sync_providers(meridian: &SqlitePool, config: &Config) -> Result<()> { let provider_count = config.pm_providers.len(); tracing::debug!(provider_count, "syncing PM providers"); @@ -220,4 +293,46 @@ mod tests { .unwrap(); assert_eq!(queued, 0, "the board hygiene digest producer was removed"); } + + /// NO TIMER MAY SYNC THE BOARD. This is a source-level guard because the + /// regression it prevents is invisible: a `run_pm_sync` call added back into + /// the daemon's poll loop would work perfectly, sync the board, pass every + /// test - and start silently destroying users' Jira OAuth grants again. + /// + /// The mechanism, in one paragraph, because it is not guessable from the call + /// site: macOS dark-wakes a sleeping machine every 15-16 minutes. A timer + /// resumed, found the access token expired, and POSTed a refresh; Atlassian + /// rotated the refresh token and replied; the machine re-suspended before the + /// response was read. `reqwest`'s timeout could not cancel it, because the + /// timeout is `Instant`-based and the monotonic clock does not advance while + /// the system is asleep. The next dark wake retried with the OLD refresh + /// token, by then past Atlassian's 10-minute reuse leeway - `invalid_grant`, + /// terminal, grant dead forever. The dark-wake interval is LONGER than the + /// leeway, which is why no retry policy fixes it and why the only fix is to + /// attempt no refresh while nobody is there. + /// + /// Every legitimate caller is a ONE-SHOT CLI arm, all of which sit before the + /// daemon boots. So the rule is positional: nothing after the poll loop + /// begins. If this fails, do not silence it - move the trigger to something a + /// present user did (see `tray/src-tauri/src/commands/tasks.rs`). + #[test] + fn the_daemon_poll_loop_never_syncs_pm_tasks() { + let main_rs = include_str!("../main.rs"); + // The loop's own `tokio::select!` is the boundary: CLI arms are all above + // it, the daemon's recurring work is all below. + let loop_start = main_rs + .find("tokio::select! {") + .expect("main.rs must still have the poll loop's tokio::select!"); + let after = &main_rs[loop_start..]; + let offenders: Vec<&str> = after + .lines() + .filter(|l| !l.trim_start().starts_with("//")) + .filter(|l| l.contains("run_pm_sync(") || l.contains("run_pm_force_sync(")) + .collect(); + assert!( + offenders.is_empty(), + "PM sync is back on a timer, which permanently kills Jira OAuth grants \ + on sleeping machines - see this test's doc comment. Offending lines: {offenders:?}" + ); + } } diff --git a/src/intelligence/sync_lock.rs b/src/intelligence/sync_lock.rs new file mode 100644 index 000000000..abfdc635a --- /dev/null +++ b/src/intelligence/sync_lock.rs @@ -0,0 +1,238 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +//! Cross-process deduplication for PM task sync. +//! +//! PM sync is on-demand (see [`crate::intelligence::run_pm_sync`]): the tray +//! triggers it by spawning `meridian pm-sync` when the user connects a tracker +//! or opens the dashboard, and the daemon triggers it before a worklog drafting +//! sweep. Several of those can land within a second of each other - opening the +//! dashboard while a drafting sweep is starting is the ordinary case, not a +//! corner one - and each would independently read `pm_sync_state`, independently +//! decide the cache is stale, and fetch the same board again. +//! +//! This is the ONE mechanism that stops that. It is deliberately a **file** lock +//! and not a table: +//! +//! * The reverted `pm_sync_requests` outbox (PRs #909/#910) coordinated the tray +//! and the daemon through `meridian.db`, and the tray's read-back of the +//! outcome is what failed in production with `SQLITE_IOERR_SHORT_READ` (522) - +//! a short read while the daemon truncated the WAL on shutdown. Coordination +//! state that lives outside the database cannot fail that way, and needs no +//! migration, so shipping it cannot damage anyone's data. +//! * A crashed holder releases automatically: the lock dies with its fd. No +//! stale-row cleanup, no `claimed_at` timestamps to reap. +//! +//! # Who calls this +//! [`crate::intelligence::run_pm_sync`] (gated - skips on contention) and +//! [`crate::intelligence::run_pm_force_sync`] (forced - waits, then proceeds). +//! +//! # Related +//! - [`meridian_oauth::store::lock_provider`] - the same advisory-file-lock +//! pattern, guarding the rotating OAuth refresh token. That one serialises +//! *token* writes and is unaffected by this module; a duplicate fetch is +//! wasteful, a duplicate token refresh is destructive, so they stay separate +//! locks with different contention policies. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +/// Held for the duration of one sync. Releasing is `Drop` (the fd closes), so a +/// panic or a killed process frees it without any cleanup path. +#[derive(Debug)] +pub struct SyncLock { + _file: std::fs::File, +} + +/// `~/.meridian/pm-sync.lock`. Alongside the OAuth store's lock files rather +/// than in a temp dir: a per-user path that survives reboots and is not shared +/// between accounts on one machine. +fn lock_path() -> Result { + let home = meridian_core::paths::home_dir() + .context("resolving the home directory for the PM sync lock")?; + Ok(home.join(".meridian").join("pm-sync.lock")) +} + +/// Open (creating if needed) the lock file. Split out so both acquire paths +/// share it and neither can drift on flags - `truncate(false)` matters: the file +/// is a lock, never a payload, and truncating it would be a pointless write on +/// every sync. +fn open_lock_file() -> Result { + let path = lock_path()?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir) + .with_context(|| format!("creating {} for the PM sync lock", dir.display()))?; + } + std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(&path) + .with_context(|| format!("opening PM sync lock {}", path.display())) +} + +/// Try to take the lock without waiting. +/// +/// `Ok(Some(_))` - acquired, this process owns the sync. +/// `Ok(None)` - another process is syncing right now; the caller should SKIP, +/// because that run is already doing the work this one wanted. +/// `Err(_)` - the lock could not be evaluated at all (no home dir, +/// unwritable `~/.meridian`). +/// +/// Used by the gated path. Skipping on contention is the whole point: two +/// concurrent gated syncs would fetch the same board twice. +pub fn try_acquire() -> Result> { + let file = open_lock_file()?; + match file.try_lock() { + Ok(()) => Ok(Some(SyncLock { _file: file })), + Err(std::fs::TryLockError::WouldBlock) => Ok(None), + Err(std::fs::TryLockError::Error(e)) => { + Err(anyhow::Error::new(e).context("evaluating the PM sync lock")) + } + } +} + +/// Wait up to `timeout` for the lock, then give up. +/// +/// `Ok(Some(_))` - acquired. `Ok(None)` - still held when the budget ran out. +/// +/// Used by the FORCED path, which must not silently skip: a user pressing "Sync +/// now" has to get a sync. Waiting is nearly always brief (a gated sync is one +/// HTTP fetch per provider), and a caller that times out is told so rather than +/// racing the holder - see [`crate::intelligence::run_pm_force_sync`]. +/// +/// Polls a non-blocking try-lock rather than blocking on `flock`, so the async +/// executor is never parked - identical reasoning to +/// [`meridian_oauth::store::lock_provider`]. +pub async fn acquire_waiting(timeout: std::time::Duration) -> Result> { + let step = std::time::Duration::from_millis(100); + let mut waited = std::time::Duration::ZERO; + loop { + if let Some(guard) = try_acquire()? { + return Ok(Some(guard)); + } + if waited >= timeout { + return Ok(None); + } + tokio::time::sleep(step).await; + waited += step; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Serialises the tests below. Both mutate `HOME` (process-global) to point + /// `lock_path` at a scratch dir, and cargo runs tests in parallel threads - + /// so without this one test's `set_var` lands mid-flight in the other and it + /// contends on the WRONG lock file. That is not hypothetical: it is exactly + /// how these two first failed. Mirrors `meridian_oauth::env_test_guard`. + fn env_guard() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|p| p.into_inner()) + } + + /// Point `HOME` at a fresh scratch dir for the duration of a test, restoring + /// it afterwards so a failure cannot leak a bogus `HOME` into later tests. + struct ScratchHome { + dir: std::path::PathBuf, + prev: Option, + _guard: std::sync::MutexGuard<'static, ()>, + } + + impl ScratchHome { + fn new(tag: &str) -> Self { + let guard = env_guard(); + let dir = std::env::temp_dir() + .join(format!("meridian_synclock_{tag}_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let prev = std::env::var_os("HOME"); + std::env::set_var("HOME", &dir); + Self { + dir, + prev, + _guard: guard, + } + } + } + + impl Drop for ScratchHome { + fn drop(&mut self) { + match self.prev.take() { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + std::fs::remove_dir_all(&self.dir).ok(); + } + } + + /// Two acquisitions contend and the second is refused, then succeeds once + /// the first is dropped. `flock` is per-open-file-description, so two opens + /// in ONE process contend exactly as two processes would - which is what + /// makes this testable without spawning anything. + #[test] + fn a_second_acquire_is_refused_until_the_first_drops() { + let _home = ScratchHome::new("basic"); + + let held = try_acquire().unwrap(); + assert!(held.is_some(), "the first acquire must succeed"); + + let contended = try_acquire().unwrap(); + assert!( + contended.is_none(), + "a gated caller must be refused while another sync holds the lock, \ + not granted a duplicate" + ); + + drop(held); + let reacquired = try_acquire().unwrap(); + assert!( + reacquired.is_some(), + "the lock must be free again once the holder drops it" + ); + } + + /// The waiting path returns `Ok(None)` rather than erroring or hanging when + /// the budget expires - the forced caller needs to distinguish "I have the + /// lock" from "someone else still does" to report honestly. + #[test] + fn waiting_gives_up_with_none_when_the_budget_expires() { + let _home = ScratchHome::new("waiting"); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + let held = try_acquire().unwrap().expect("first acquire"); + let timed_out = rt.block_on(acquire_waiting(std::time::Duration::from_millis(250))); + assert!( + matches!(timed_out, Ok(None)), + "a contended wait must expire as Ok(None), got {timed_out:?}" + ); + drop(held); + } + + /// The waiting path takes a FREE lock immediately rather than sleeping out + /// its budget - the forced path runs on a user's click, so a 20 s wait for + /// an uncontended lock would be a visible regression. + #[test] + fn waiting_acquires_immediately_when_uncontended() { + let _home = ScratchHome::new("free"); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap(); + let started = std::time::Instant::now(); + let got = rt.block_on(acquire_waiting(std::time::Duration::from_secs(20))); + assert!( + matches!(got, Ok(Some(_))), + "an uncontended wait must acquire, got {got:?}" + ); + assert!( + started.elapsed() < std::time::Duration::from_secs(1), + "an uncontended wait must not sleep - took {:?}", + started.elapsed() + ); + } +} diff --git a/src/main.rs b/src/main.rs index f52889729..5da6f296b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -373,6 +373,38 @@ async fn main() -> Result<()> { return Ok(()); } + // `meridian pm-sync` — the GATED sibling of `tasks-sync`: sync all configured + // PM providers, but honour each provider's staleness gate so a fresh cache is + // a cheap no-op. This is what every ON-DEMAND trigger spawns (tracker + // connected, dashboard opened), which is why it must not be `tasks-sync`: + // those triggers can fire repeatedly within seconds and a forced fetch each + // time would be exactly the API hammering the timer used to do. + // + // Exits 0 whether it fetched, skipped as fresh, or skipped because another + // sync held the dedup lock — all three are successful outcomes for a + // best-effort trigger, and only a DB that cannot be opened is a real failure. + if std::env::args().nth(1).as_deref() == Some("pm-sync") { + let cfg = Config::from_env(); + match setup_db(&cfg.meridian_db_uri()).await { + Ok(pool) => { + if let Err(e) = run_pm_sync(&pool, &cfg).await { + eprintln!("pm-sync: {}", meridian::errors::chain(&e)); + } + // Close the pool explicitly, same as `tasks-sync`: this is a + // short-lived process writing the SAME meridian.db the daemon and + // tray hold open, and dropping a pool without closing it can + // leave the WAL un-checkpointed behind a connection the OS is + // still tearing down. + pool.close().await; + } + Err(e) => { + eprintln!("pm-sync: open db: {e:#}"); + std::process::exit(1); + } + } + return Ok(()); + } + // `meridian ticket-update --provider P --key K --field F --value V` — apply // ONE board-hygiene fix to the real tracker (due date, assignee, label, …). // Prints a JSON result the UI reads: {"status":"applied"} or @@ -694,6 +726,18 @@ async fn main() -> Result<()> { let obs_guard = observability::init("meridian-rust").ok(); match setup_db(&cfg.meridian_db_uri()).await { Ok(pool) => { + // Refresh the board BEFORE matching, now that nothing does it on + // a timer. A stale `pm_tasks` is not a cosmetic problem here: it + // is what silently binds an hour of work to a ticket that was + // closed or reassigned hours ago. Gated (a fresh cache no-ops) + // and log-and-continue — drafting from a slightly stale board + // beats not drafting at all. + if let Err(e) = run_pm_sync(&pool, &cfg).await { + tracing::warn!( + error = %meridian::errors::chain(&e), + "worklog-generate: pre-draft PM sync failed — matching against the cached board" + ); + } match meridian::pm_worklog::generate(&pool, &cfg, &day, &task_id).await { Ok(mut draft) => { // A matched draft carries a target_key we can link even @@ -1355,9 +1399,12 @@ async fn main() -> Result<()> { } // 7c. Run ETL once immediately before entering the loop. - // Re-read config so that any settings.json present at startup takes effect. + // + // No `Config::from_env()` re-read here any more: it existed only to hand + // a fresh config to the startup PM sync that used to live at the end of + // this block, and nothing else in the block reads it. ETL and the + // retention sweep take the pool alone. { - let cfg = Config::from_env(); let startup_tick = tracing::info_span!("startup_tick"); *etl_tick_span.lock().unwrap_or_else(|e| e.into_inner()) = Some(startup_tick.clone()); let _guard = startup_tick.enter(); @@ -1381,12 +1428,11 @@ async fn main() -> Result<()> { tracing::warn!(error = %meridian::errors::chain(&e), "capture retention sweep failed"); } } - if let Err(e) = run_pm_sync(&meridian, &cfg).await { - tracing::error!( - error = %meridian::errors::chain(&e), - "intelligence run failed" - ); - } + // No startup PM sync either, for the same reason as the poll tick above: + // launchd `KeepAlive` restarts this process on every wake, so a + // sync-on-boot IS a wake-triggered token refresh wearing different + // clothes. Nothing at startup needs a fresh board - `pm_tasks_present` + // and `daily_plan::maybe_nudge` only check EXISTENCE, not freshness. } let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); @@ -1605,17 +1651,38 @@ async fn main() -> Result<()> { tracing::debug!(error = %meridian::errors::chain(&e), "notification response consume skipped"); } - // Refresh the PM task cache (pm_tasks) every tick — interval-gated - // per provider (~5 min), so this is a cheap no-op most ticks. The - // legacy drafting driver that used to trigger this before every - // pass was retired when the worklog pipeline moved to the - // clock-aligned Python trigger, which never calls this itself — - // leaving pm_tasks (and hence a ticket's title on the timeline) - // stuck at whatever it was at the last daemon restart. This is - // the only thing that keeps it live during normal operation. - if let Err(e) = run_pm_sync(&meridian, &cfg).await { - tracing::warn!(error = %meridian::errors::chain(&e), "pm_tasks refresh failed — using cached tasks"); - } + // ── NO PM SYNC ON THE POLL TICK. ──────────────────────────────────── + // + // This used to call `run_pm_sync` every tick (~60 s), interval-gated per + // provider to ~5 min. It was removed because the timer, not the network, + // is what was permanently destroying production users' Jira grants. + // + // macOS dark-wakes a sleeping machine every 15-16 minutes. The daemon + // resumed, found the OAuth access token expired, and POSTed a refresh. + // Atlassian rotated the refresh token and replied - and the machine + // re-suspended before the response was read. `reqwest`'s timeout could + // not save it: the timeout is `Instant`-based and the monotonic clock + // does not advance while the system is asleep, so the request simply + // hung. On the NEXT dark wake, 15-16 minutes later, the socket was dead + // and the retry went out with the OLD refresh token - now past + // Atlassian's 10-minute reuse leeway. `invalid_grant`, classified + // terminal, grant dead forever, user sees "refresh_token is invalid". + // + // The dark-wake interval being LONGER than the leeway is why no retry + // policy can fix this from inside the daemon; see + // `~/.claude` notes and `meridian-oauth::jira::ensure_fresh`. + // + // A sleeping machine has no work to do, so it must attempt no token + // refreshes. Nothing here needs `pm_tasks` fresh on a clock either: the + // hourly worklog fold never reads it, and the one place that matches + // sessions to tickets now syncs for itself + // (`pm_worklog::auto_generate::sweep::draft_qualifying_tasks`). The + // remaining triggers are all things a PRESENT user did - connecting a + // tracker, opening the dashboard, pressing Sync now, a tracker write - + // and they live in `tray/src-tauri/src/commands/tasks.rs`. + // + // Do not reintroduce a timer here. If something needs a fresher board, + // give it its own trigger and let `intelligence::sync_lock` dedupe it. } } } diff --git a/src/pm_worklog/auto_generate.rs b/src/pm_worklog/auto_generate/mod.rs similarity index 52% rename from src/pm_worklog/auto_generate.rs rename to src/pm_worklog/auto_generate/mod.rs index 8e693e819..b8446b205 100644 --- a/src/pm_worklog/auto_generate.rs +++ b/src/pm_worklog/auto_generate/mod.rs @@ -93,7 +93,10 @@ use tracing::field::Empty; use tracing::Instrument; use crate::config::Config; -use crate::pm_worklog; + +mod sweep; + +use sweep::draft_qualifying_tasks; /// Fixed qualifying threshold — a day-task needs more than this many tracked /// minutes to be auto-drafted. Not user-configurable: only WHEN Meridian checks @@ -187,267 +190,9 @@ pub async fn generate_now(pool: &SqlitePool, config: &Config, day_local: &str) { .await } -/// What one sweep did. Returned rather than only recorded onto the span so the -/// behaviour is assertable: "did this run at all" and "did it early-return" are -/// otherwise indistinguishable from outside, which is exactly how a tracker gate -/// silently disabled the whole feature for solo users once already. -#[derive(Debug, Default, PartialEq, Eq)] -struct SweepCounts { - total: usize, - qualifying: u32, - already_drafted: u32, - drafted: u32, - failed: u32, -} - -/// Draft a worklog for every qualifying, not-yet-drafted day-task of `day_local`, -/// recording per-run counts onto `span` and returning them. The shared body of -/// [`maybe_auto_generate`] (gated on the clock) and [`generate_now`] (on demand); it -/// assumes its caller has already decided a run is warranted. -/// -/// Deliberately NOT gated on a connected tracker - a personal day-task is matched and -/// drafted like any ticket, and only the propose branch needs one (it already fails -/// per-task). See [`neither_sweep_is_gated_on_a_connected_tracker`]. -async fn draft_qualifying_tasks( - pool: &SqlitePool, - config: &Config, - day_local: &str, - span: &tracing::Span, -) -> SweepCounts { - let tasks = match meridian_core::day_tasks::get_day_tasks(pool, day_local).await { - Ok(resp) => resp.tasks, - Err(e) => { - tracing::warn!( - day = day_local, error = %e, - "worklog: auto-generate day-task read failed — skipping this run" - ); - return SweepCounts::default(); - } - }; - span.record("tasks_total", tasks.len()); - let total = tasks.len(); - - let mut qualifying = 0u32; - let mut already_drafted = 0u32; - let mut drafted = 0u32; - let mut failed = 0u32; - - for task in tasks { - if task.minutes < QUALIFYING_MINUTES { - continue; - } - qualifying += 1; - - // Already drafted — by an earlier auto-generate run, or by the user - // clicking "Generate worklog" themselves. Either way this task is now the - // user's to drive (Regenerate in the panel), never auto-generate's again. - match meridian_core::day_task_worklogs::get_day_task_worklog(pool, day_local, &task.id) - .await - { - Ok(Some(_)) => { - already_drafted += 1; - continue; - } - Ok(None) => {} - Err(e) => { - failed += 1; - tracing::warn!( - day = day_local, task_id = %task.id, error = %e, - "worklog: auto-generate existing-draft check failed — skipping this task this run" - ); - continue; - } - } - - tracing::info!( - day = day_local, task_id = %task.id, minutes = task.minutes, - "worklog: threshold crossed — drafting" - ); - match pm_worklog::generate(pool, config, day_local, &task.id).await { - Ok(draft) => { - drafted += 1; - tracing::info!( - day = day_local, task_id = %task.id, state = draft.state, - "worklog: auto-generated (draft only — never posted)" - ); - } - Err(e) => { - failed += 1; - tracing::warn!( - day = day_local, task_id = %task.id, error = %e, - "worklog: auto-generate failed — the user can still generate it manually" - ); - } - } - } - - span.record("tasks_qualifying", qualifying); - span.record("tasks_already_drafted", already_drafted); - span.record("tasks_drafted", drafted); - span.record("tasks_failed", failed); - tracing::info!( - day = day_local, - qualifying, - already_drafted, - drafted, - failed, - "worklog: auto-generate run complete" - ); - - SweepCounts { - total, - qualifying, - already_drafted, - drafted, - failed, - } -} - #[cfg(test)] mod tests { - use super::{draft_qualifying_tasks, parse_hh_mm, SweepCounts, QUALIFYING_MINUTES}; - use crate::config::Config; - use sqlx::sqlite::SqlitePoolOptions; - use sqlx::SqlitePool; - - const DAY: &str = "2026-08-07"; - - /// The two tables the sweep reads: day-tasks to enumerate, worklogs to skip - /// ones already drafted. `pm_providers` is a CONFIG field, not a table, so a - /// tracker-less user is modelled by the empty `Config` below. - async fn seeded() -> SqlitePool { - let pool = SqlitePoolOptions::new() - .max_connections(1) - .connect("sqlite::memory:") - .await - .unwrap(); - for ddl in [ - "CREATE TABLE day_tasks (day_local TEXT NOT NULL, task_id TEXT NOT NULL, \ - title TEXT, summary TEXT, hours_json TEXT, segments_json TEXT, \ - minutes INTEGER, status TEXT, linked_ticket TEXT, \ - PRIMARY KEY (day_local, task_id))", - // Mirrors `meridian-core/src/readers/day_task_worklogs/tests.rs`'s fixture - - // `get_day_task_worklog` reads the targets table too, and a column short of - // it the read fails and every task counts as `failed` rather than - // `already_drafted`, which would make this test pass for the wrong reason. - "CREATE TABLE day_task_worklogs (day_local TEXT NOT NULL, task_id TEXT NOT NULL, \ - provider TEXT NOT NULL DEFAULT 'local', state TEXT NOT NULL DEFAULT 'drafted', \ - update_summary TEXT NOT NULL DEFAULT '', update_json TEXT NOT NULL DEFAULT '{}', \ - reasoning TEXT NOT NULL DEFAULT '', propose_issue_type TEXT, propose_title TEXT, \ - propose_description TEXT, created_task_key TEXT, last_error TEXT, \ - create_attempt_at TEXT, drafted_minutes INTEGER, \ - created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '', \ - PRIMARY KEY (day_local, task_id))", - "CREATE TABLE day_task_worklog_targets (day_local TEXT NOT NULL, \ - task_id TEXT NOT NULL, task_key TEXT NOT NULL, provider TEXT NOT NULL, \ - confidence REAL NOT NULL DEFAULT 0, manual INTEGER NOT NULL DEFAULT 0, \ - position INTEGER NOT NULL DEFAULT 0, posted_comment_id TEXT, browse_url TEXT, \ - posted_at TEXT, last_error TEXT, post_attempt_at TEXT, \ - created_at TEXT NOT NULL DEFAULT '', update_json TEXT, \ - PRIMARY KEY (day_local, task_id, task_key))", - "CREATE TABLE pm_tasks (task_key TEXT PRIMARY KEY, title TEXT NOT NULL)", - ] { - sqlx::query(ddl).execute(&pool).await.unwrap(); - } - pool - } - - async fn put_task(pool: &SqlitePool, task_id: &str, minutes: i64) { - sqlx::query( - "INSERT INTO day_tasks (day_local, task_id, title, minutes) VALUES (?, ?, ?, ?)", - ) - .bind(DAY) - .bind(task_id) - .bind(format!("Task {task_id}")) - .bind(minutes) - .execute(pool) - .await - .unwrap(); - } - - async fn put_draft(pool: &SqlitePool, task_id: &str) { - sqlx::query("INSERT INTO day_task_worklogs (day_local, task_id) VALUES (?, ?)") - .bind(DAY) - .bind(task_id) - .execute(pool) - .await - .unwrap(); - } - - /// A user with NO tracker connected - the exact configuration the removed - /// gate used to abandon. - fn no_tracker_config() -> Config { - Config { - meridian_db: ":memory:".into(), - poll_interval_secs: 60, - pm_providers: Vec::new(), - jira_update_enabled: false, - jira_update_interval_s: 14_400, - jira_office_start_hour: 9, - jira_office_end_hour: 17, - runtime: Default::default(), - } - } - - /// The functional half of [`neither_sweep_is_gated_on_a_connected_tracker`]. - /// - /// That test greps the source for the gate's return; this one proves the sweep - /// actually walks its task list with `pm_providers` empty. The two together - /// close the hole: a gate written a DIFFERENT way (a different field, an early - /// `return` above the read) would slip past the grep, but not past this. - /// - /// Every task here is pre-drafted on purpose, so the loop reaches its - /// already-drafted branch and returns without ever calling `pm_worklog::generate` - /// - no LLM, no network, no CLI spawn. Counting the tasks it CONSIDERED is - /// enough: an early return yields zeroes, and nothing else does. - #[tokio::test] - async fn the_sweep_walks_its_tasks_with_no_tracker_connected() { - let pool = seeded().await; - put_task(&pool, "T1", QUALIFYING_MINUTES + 5).await; - put_task(&pool, "T2", QUALIFYING_MINUTES + 90).await; - put_draft(&pool, "T1").await; - put_draft(&pool, "T2").await; - - let counts = - draft_qualifying_tasks(&pool, &no_tracker_config(), DAY, &tracing::Span::none()).await; - - assert_eq!( - counts, - SweepCounts { - total: 2, - qualifying: 2, - already_drafted: 2, - drafted: 0, - failed: 0, - }, - "a tracker-less user's tasks must still be considered" - ); - } - - /// The threshold still applies - "not gated on a tracker" must not become - /// "not gated at all". - #[tokio::test] - async fn short_tasks_are_still_below_the_threshold() { - let pool = seeded().await; - put_task(&pool, "T1", QUALIFYING_MINUTES - 1).await; - put_draft(&pool, "T1").await; - - let counts = - draft_qualifying_tasks(&pool, &no_tracker_config(), DAY, &tracing::Span::none()).await; - - assert_eq!(counts.total, 1); - assert_eq!(counts.qualifying, 0, "under the threshold, never drafted"); - assert_eq!(counts.already_drafted, 0); - } - - /// A day with nothing on it returns cleanly rather than erroring. - #[tokio::test] - async fn an_empty_day_sweeps_to_zero() { - let pool = seeded().await; - let counts = - draft_qualifying_tasks(&pool, &no_tracker_config(), DAY, &tracing::Span::none()).await; - assert_eq!(counts, SweepCounts::default()); - } + use super::parse_hh_mm; #[test] fn parses_valid_times() { @@ -474,15 +219,26 @@ mod tests { /// output to assert on - it just made the feature silently do nothing for every /// tracker-less user, in a build that otherwise looked healthy. Drafting works /// without a tracker (a personal day-task is matched and drafted like any + /// The tracker gate must not come back, in EITHER half of this module. + /// + /// Source-level because the gate was a bare early return with no observable + /// output to assert on - it just made the feature silently do nothing for every + /// tracker-less user, in a build that otherwise looked healthy. Drafting works + /// without a tracker (a personal day-task is matched and drafted like any /// ticket); only the propose branch needs one, and it already fails per-task. + /// + /// Scans `mod.rs` AND `sweep.rs`. When this module was one file the scan was + /// one `include_str!`; splitting it for the 500-line cap would have quietly + /// halved the guard's reach, leaving the sweep itself - the half that + /// actually enumerates tasks - uncovered while the test still passed. #[test] fn neither_sweep_is_gated_on_a_connected_tracker() { - let src = include_str!("auto_generate.rs"); + let src = concat!(include_str!("mod.rs"), "\n", include_str!("sweep.rs")); // Comment lines dropped, not just their markers - the module doc above // quotes the removed gate verbatim to explain why it went. let code: String = src .lines() - .filter(|l| !l.trim_start().starts_with("//")) + .filter(|l: &&str| !l.trim_start().starts_with("//")) .collect::>() .join("\n"); // Assembled, not written out: a literal would match itself on this very line. diff --git a/src/pm_worklog/auto_generate/sweep.rs b/src/pm_worklog/auto_generate/sweep.rs new file mode 100644 index 000000000..f0f66e796 --- /dev/null +++ b/src/pm_worklog/auto_generate/sweep.rs @@ -0,0 +1,308 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +//! The auto-generate SWEEP: refresh the board, then draft a worklog for every +//! qualifying, not-yet-drafted day-task. +//! +//! Split out of [`super`] purely for the 500-line file cap; the clock gate that +//! decides WHEN a sweep runs stays there, and this is WHAT a sweep does. Both of +//! `super`'s entry points ([`super::maybe_auto_generate`], the daily clock +//! trigger, and [`super::generate_now`], the on-demand one) funnel through +//! [`draft_qualifying_tasks`], which is the single place that refreshes +//! `pm_tasks` before matching - see its body for why that call cannot live in +//! the two callers instead. +//! +//! # Who calls this +//! [`super::run`] and [`super::generate_now`], via [`draft_qualifying_tasks`]. +//! Nothing outside `auto_generate` may call it - the clock/consent gate is +//! `super`'s job. +//! +//! # Related +//! - [`crate::intelligence::run_pm_sync`] - the gated board refresh this runs +//! first, and [`crate::intelligence::sync_lock`] for why a concurrent sweep +//! and dashboard-open do not both fetch. +//! - [`crate::pm_worklog::generate`] - the per-task draft this loop calls. + +use sqlx::SqlitePool; + +use crate::config::Config; +use crate::pm_worklog; + +use super::QUALIFYING_MINUTES; + +/// What one sweep did. Returned rather than only recorded onto the span so the +/// behaviour is assertable: "did this run at all" and "did it early-return" are +/// otherwise indistinguishable from outside, which is exactly how a tracker gate +/// silently disabled the whole feature for solo users once already. +#[derive(Debug, Default, PartialEq, Eq)] +pub(super) struct SweepCounts { + pub(super) total: usize, + pub(super) qualifying: u32, + pub(super) already_drafted: u32, + pub(super) drafted: u32, + pub(super) failed: u32, +} + +/// Draft a worklog for every qualifying, not-yet-drafted day-task of `day_local`, +/// recording per-run counts onto `span` and returning them. The shared body of +/// [`maybe_auto_generate`] (gated on the clock) and [`generate_now`] (on demand); it +/// assumes its caller has already decided a run is warranted. +/// +/// Deliberately NOT gated on a connected tracker - a personal day-task is matched and +/// drafted like any ticket, and only the propose branch needs one (it already fails +/// per-task). See [`neither_sweep_is_gated_on_a_connected_tracker`]. +pub(super) async fn draft_qualifying_tasks( + pool: &SqlitePool, + config: &Config, + day_local: &str, + span: &tracing::Span, +) -> SweepCounts { + // Refresh the board before matching work to tickets. Nothing does this on a + // timer any more (see `main.rs`'s poll loop), and this is the one consumer + // where a stale `pm_tasks` is actively harmful rather than merely cosmetic: + // matching against a ticket that was closed or reassigned hours ago writes a + // wrong worklog, which is worse than writing none. + // + // Placed HERE rather than in `maybe_auto_generate` and `generate_now` + // separately, so both sweeps get it from one call site and the two can never + // disagree about whether a run refreshed first. Gated (a fresh cache is a + // no-op) and log-and-continue: a failed sync must not cancel drafting. + if let Err(e) = crate::intelligence::run_pm_sync(pool, config).await { + tracing::warn!( + day = day_local, error = %crate::errors::chain(&e), + "worklog: pre-draft PM sync failed — matching against the cached board" + ); + } + let tasks = match meridian_core::day_tasks::get_day_tasks(pool, day_local).await { + Ok(resp) => resp.tasks, + Err(e) => { + tracing::warn!( + day = day_local, error = %e, + "worklog: auto-generate day-task read failed — skipping this run" + ); + return SweepCounts::default(); + } + }; + span.record("tasks_total", tasks.len()); + let total = tasks.len(); + + let mut qualifying = 0u32; + let mut already_drafted = 0u32; + let mut drafted = 0u32; + let mut failed = 0u32; + + for task in tasks { + if task.minutes < QUALIFYING_MINUTES { + continue; + } + qualifying += 1; + + // Already drafted — by an earlier auto-generate run, or by the user + // clicking "Generate worklog" themselves. Either way this task is now the + // user's to drive (Regenerate in the panel), never auto-generate's again. + match meridian_core::day_task_worklogs::get_day_task_worklog(pool, day_local, &task.id) + .await + { + Ok(Some(_)) => { + already_drafted += 1; + continue; + } + Ok(None) => {} + Err(e) => { + failed += 1; + tracing::warn!( + day = day_local, task_id = %task.id, error = %e, + "worklog: auto-generate existing-draft check failed — skipping this task this run" + ); + continue; + } + } + + tracing::info!( + day = day_local, task_id = %task.id, minutes = task.minutes, + "worklog: threshold crossed — drafting" + ); + match pm_worklog::generate(pool, config, day_local, &task.id).await { + Ok(draft) => { + drafted += 1; + tracing::info!( + day = day_local, task_id = %task.id, state = draft.state, + "worklog: auto-generated (draft only — never posted)" + ); + } + Err(e) => { + failed += 1; + tracing::warn!( + day = day_local, task_id = %task.id, error = %e, + "worklog: auto-generate failed — the user can still generate it manually" + ); + } + } + } + + span.record("tasks_qualifying", qualifying); + span.record("tasks_already_drafted", already_drafted); + span.record("tasks_drafted", drafted); + span.record("tasks_failed", failed); + tracing::info!( + day = day_local, + qualifying, + already_drafted, + drafted, + failed, + "worklog: auto-generate run complete" + ); + + SweepCounts { + total, + qualifying, + already_drafted, + drafted, + failed, + } +} + +#[cfg(test)] +mod tests { + use super::{draft_qualifying_tasks, SweepCounts, QUALIFYING_MINUTES}; + use crate::config::Config; + use sqlx::sqlite::SqlitePoolOptions; + use sqlx::SqlitePool; + + const DAY: &str = "2026-08-07"; + + /// The two tables the sweep reads: day-tasks to enumerate, worklogs to skip + /// ones already drafted. `pm_providers` is a CONFIG field, not a table, so a + /// tracker-less user is modelled by the empty `Config` below. + async fn seeded() -> SqlitePool { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + for ddl in [ + "CREATE TABLE day_tasks (day_local TEXT NOT NULL, task_id TEXT NOT NULL, \ + title TEXT, summary TEXT, hours_json TEXT, segments_json TEXT, \ + minutes INTEGER, status TEXT, linked_ticket TEXT, \ + PRIMARY KEY (day_local, task_id))", + // Mirrors `meridian-core/src/readers/day_task_worklogs/tests.rs`'s fixture - + // `get_day_task_worklog` reads the targets table too, and a column short of + // it the read fails and every task counts as `failed` rather than + // `already_drafted`, which would make this test pass for the wrong reason. + "CREATE TABLE day_task_worklogs (day_local TEXT NOT NULL, task_id TEXT NOT NULL, \ + provider TEXT NOT NULL DEFAULT 'local', state TEXT NOT NULL DEFAULT 'drafted', \ + update_summary TEXT NOT NULL DEFAULT '', update_json TEXT NOT NULL DEFAULT '{}', \ + reasoning TEXT NOT NULL DEFAULT '', propose_issue_type TEXT, propose_title TEXT, \ + propose_description TEXT, created_task_key TEXT, last_error TEXT, \ + create_attempt_at TEXT, drafted_minutes INTEGER, \ + created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '', \ + PRIMARY KEY (day_local, task_id))", + "CREATE TABLE day_task_worklog_targets (day_local TEXT NOT NULL, \ + task_id TEXT NOT NULL, task_key TEXT NOT NULL, provider TEXT NOT NULL, \ + confidence REAL NOT NULL DEFAULT 0, manual INTEGER NOT NULL DEFAULT 0, \ + position INTEGER NOT NULL DEFAULT 0, posted_comment_id TEXT, browse_url TEXT, \ + posted_at TEXT, last_error TEXT, post_attempt_at TEXT, \ + created_at TEXT NOT NULL DEFAULT '', update_json TEXT, \ + PRIMARY KEY (day_local, task_id, task_key))", + "CREATE TABLE pm_tasks (task_key TEXT PRIMARY KEY, title TEXT NOT NULL)", + ] { + sqlx::query(ddl).execute(&pool).await.unwrap(); + } + pool + } + + async fn put_task(pool: &SqlitePool, task_id: &str, minutes: i64) { + sqlx::query( + "INSERT INTO day_tasks (day_local, task_id, title, minutes) VALUES (?, ?, ?, ?)", + ) + .bind(DAY) + .bind(task_id) + .bind(format!("Task {task_id}")) + .bind(minutes) + .execute(pool) + .await + .unwrap(); + } + + async fn put_draft(pool: &SqlitePool, task_id: &str) { + sqlx::query("INSERT INTO day_task_worklogs (day_local, task_id) VALUES (?, ?)") + .bind(DAY) + .bind(task_id) + .execute(pool) + .await + .unwrap(); + } + + /// A user with NO tracker connected - the exact configuration the removed + /// gate used to abandon. + fn no_tracker_config() -> Config { + Config { + meridian_db: ":memory:".into(), + poll_interval_secs: 60, + pm_providers: Vec::new(), + jira_update_enabled: false, + jira_update_interval_s: 14_400, + jira_office_start_hour: 9, + jira_office_end_hour: 17, + runtime: Default::default(), + } + } + + /// The functional half of [`neither_sweep_is_gated_on_a_connected_tracker`]. + /// + /// That test greps the source for the gate's return; this one proves the sweep + /// actually walks its task list with `pm_providers` empty. The two together + /// close the hole: a gate written a DIFFERENT way (a different field, an early + /// `return` above the read) would slip past the grep, but not past this. + /// + /// Every task here is pre-drafted on purpose, so the loop reaches its + /// already-drafted branch and returns without ever calling `pm_worklog::generate` + /// - no LLM, no network, no CLI spawn. Counting the tasks it CONSIDERED is + /// enough: an early return yields zeroes, and nothing else does. + #[tokio::test] + async fn the_sweep_walks_its_tasks_with_no_tracker_connected() { + let pool = seeded().await; + put_task(&pool, "T1", QUALIFYING_MINUTES + 5).await; + put_task(&pool, "T2", QUALIFYING_MINUTES + 90).await; + put_draft(&pool, "T1").await; + put_draft(&pool, "T2").await; + + let counts = + draft_qualifying_tasks(&pool, &no_tracker_config(), DAY, &tracing::Span::none()).await; + + assert_eq!( + counts, + SweepCounts { + total: 2, + qualifying: 2, + already_drafted: 2, + drafted: 0, + failed: 0, + }, + "a tracker-less user's tasks must still be considered" + ); + } + + /// The threshold still applies - "not gated on a tracker" must not become + /// "not gated at all". + #[tokio::test] + async fn short_tasks_are_still_below_the_threshold() { + let pool = seeded().await; + put_task(&pool, "T1", QUALIFYING_MINUTES - 1).await; + put_draft(&pool, "T1").await; + + let counts = + draft_qualifying_tasks(&pool, &no_tracker_config(), DAY, &tracing::Span::none()).await; + + assert_eq!(counts.total, 1); + assert_eq!(counts.qualifying, 0, "under the threshold, never drafted"); + assert_eq!(counts.already_drafted, 0); + } + + /// A day with nothing on it returns cleanly rather than erroring. + #[tokio::test] + async fn an_empty_day_sweeps_to_zero() { + let pool = seeded().await; + let counts = + draft_qualifying_tasks(&pool, &no_tracker_config(), DAY, &tracing::Span::none()).await; + assert_eq!(counts, SweepCounts::default()); + } +} diff --git a/tray/src-tauri/src/commands/integrations.rs b/tray/src-tauri/src/commands/integrations.rs index 5dbe8b53a..c075ce230 100644 --- a/tray/src-tauri/src/commands/integrations.rs +++ b/tray/src-tauri/src/commands/integrations.rs @@ -681,6 +681,17 @@ pub async fn save_integration_token( tracing::debug!("daemon reload after token save (non-fatal — will pick up on next start)"); } + // POPULATE THE BOARD NOW. Nothing syncs on a timer any more (see + // `commands::tasks`'s header for why that timer was destroying OAuth + // grants), so without this a freshly connected tracker showed an empty + // board until the user happened to reopen the dashboard. + // + // FORCED, not gated: `pm_sync_state` can say "synced 2 minutes ago" from + // a previous account or provider while `pm_tasks` still holds that other + // board's tickets, and the staleness gate would wrongly skip. Connecting + // is rare and user-initiated, so a guaranteed fetch is the right cost. + crate::commands::tasks::trigger_background_pm_force_sync("token_connected"); + Ok(serde_json::json!({ "ok": true, "reloaded": reloaded })) } @@ -1343,7 +1354,19 @@ fn start_oauth_in_process(provider: String) -> Result tracing::info!(provider = %task_provider, "in-process OAuth login succeeded"), + Ok(()) => { + tracing::info!(provider = %task_provider, "in-process OAuth login succeeded"); + // POPULATE THE BOARD NOW. Nothing syncs on a timer any more (see + // `commands::tasks`'s header for why that timer was destroying OAuth + // grants), so without this a freshly connected tracker showed an empty + // board until the user happened to reopen the dashboard. + // + // FORCED, not gated: `pm_sync_state` can say "synced 2 minutes ago" from + // a previous account or provider while `pm_tasks` still holds that other + // board's tickets, and the staleness gate would wrongly skip. Connecting + // is rare and user-initiated, so a guaranteed fetch is the right cost. + crate::commands::tasks::trigger_background_pm_force_sync("oauth_connected"); + } Err(e) => { let msg = format!("{e:#}"); tracing::warn!(provider = %task_provider, error = %msg, "in-process OAuth login failed"); @@ -1461,6 +1484,16 @@ async fn start_oauth_github_device( if let Err(e) = crate::commands::daemon::reload_daemon_with(db_pool).await { tracing::debug!(error = %e, "daemon reload after GitHub connect (non-fatal)"); } + // POPULATE THE BOARD NOW. Nothing syncs on a timer any more (see + // `commands::tasks`'s header for why that timer was destroying OAuth + // grants), so without this a freshly connected tracker showed an empty + // board until the user happened to reopen the dashboard. + // + // FORCED, not gated: `pm_sync_state` can say "synced 2 minutes ago" from + // a previous account or provider while `pm_tasks` still holds that other + // board's tickets, and the staleness gate would wrongly skip. Connecting + // is rare and user-initiated, so a guaranteed fetch is the right cost. + crate::commands::tasks::trigger_background_pm_force_sync("oauth_connected"); } Err(e) => { let msg = format!("{e:#}"); diff --git a/tray/src-tauri/src/commands/system.rs b/tray/src-tauri/src/commands/system.rs index 48d8c364d..d09390b0b 100644 --- a/tray/src-tauri/src/commands/system.rs +++ b/tray/src-tauri/src/commands/system.rs @@ -42,6 +42,16 @@ pub async fn open_dashboard(app: tauri::AppHandle) -> Result<(), String> { crate::tray::open_wizard_window(&app); return Ok(()); } + // ON-DEMAND PM SYNC. The daemon no longer syncs the board on a timer (see + // `commands::tasks`'s header for why that timer was killing OAuth grants), + // so opening the dashboard is one of the moments that has to ask for a + // refresh. Placed AFTER the onboarding gate: a fresh install has no tracker + // to sync and the redirect above returns before reaching here. + // + // Fire-and-forget and gated - the window paints from the cached board + // immediately and never waits on the network, and a re-open seconds later + // no-ops on the staleness gate rather than re-fetching. + crate::commands::tasks::trigger_background_pm_sync("dashboard_open"); dismiss_popover(&app); if let Some(win) = app.get_webview_window("dashboard") { let _ = win.show(); diff --git a/tray/src-tauri/src/commands/tasks.rs b/tray/src-tauri/src/commands/tasks.rs index 882eb515e..97b58e872 100644 --- a/tray/src-tauri/src/commands/tasks.rs +++ b/tray/src-tauri/src/commands/tasks.rs @@ -8,13 +8,35 @@ //! not in meridian-core. (The per-task *read*, `get_tasks`, stays in //! [`crate::commands::dashboard`].) //! +//! Also home to the ON-DEMAND sync triggers that replaced the daemon's standing +//! timer. PM sync used to run on every poll tick forever, which is what put a +//! rotating-OAuth-token refresh POST in flight during a macOS dark wake: the +//! machine re-suspended mid-request, the reply was lost, and the retry landed +//! outside Atlassian's 10-minute reuse leeway, killing the grant permanently. +//! A sleeping machine has no work to do, so it should attempt no refreshes - +//! these triggers fire on things a PRESENT user did instead. +//! +//! # Why a process spawn and not a table +//! The reverted `pm_sync_requests` outbox (PRs #909/#910) coordinated the tray +//! and the daemon through `meridian.db`, and the tray's read-back of the outcome +//! is what failed in production with `SQLITE_IOERR_SHORT_READ` (522) - a short +//! read while the daemon truncated the WAL on shutdown. These triggers have no +//! outcome to read: they spawn `meridian pm-sync` and forget it. There is no +//! handshake, no new table, and no migration, so shipping them cannot damage a +//! database or leave a request stuck. +//! //! # Who calls this //! Registered in `lib.rs`'s `invoke_handler!`; consumed by `TasksView.tsx`'s Sync //! button via `ui/lib/bridge.ts::mutate` (success → re-fetch; error → inline msg). +//! [`trigger_background_pm_sync`] is called by [`crate::commands::system::open_dashboard`] +//! and [`crate::tray`]'s menu opener; [`trigger_background_pm_force_sync`] by +//! [`crate::commands::integrations`] on a successful tracker connect. //! //! # Related //! - [`crate::install::meridian_bin`] — the shared native-first binary resolver. //! - [`crate::commands::parents`] — the other read-side `meridian` CLI shell-out. +//! - `meridian::intelligence::sync_lock` — the cross-process lock that stops two +//! near-simultaneous triggers both fetching the same board. use meridian_core::proc_ext::NoWindow; use serde::Serialize; @@ -40,6 +62,52 @@ pub struct SyncResult { #[tauri::command] #[tracing::instrument] pub async fn sync_tasks() -> Result { + let output = spawn_meridian_cli("tasks-sync", SYNC_TIMEOUT).await?; + + if output.status.success() { + let detail = String::from_utf8_lossy(&output.stdout).trim().to_string(); + tracing::info!("tasks-sync ok"); + Ok(SyncResult { ok: true, detail }) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + // Log the CLI's own reason, not just the exit code. `status=Some(1)` alone + // says nothing — the failure is always explained in stderr, and dropping it + // turned a one-line diagnosis into a manual re-run of the subcommand. + tracing::warn!( + status = ?output.status.code(), + stderr = %stderr, + "tasks-sync non-zero" + ); + Err(if stderr.is_empty() { + "tasks-sync failed".to_string() + } else { + stderr + }) + } +} + +/// Budget for a background trigger's `meridian pm-sync`. +/// +/// Longer than [`SYNC_TIMEOUT`] because nobody is watching a spinner - but still +/// bounded, and bounded for a specific reason: a `pm-sync` whose HTTP request +/// straddles a system suspend does NOT time out on its own (`reqwest`'s timeout +/// is `Instant`-based, and the monotonic clock does not advance while macOS is +/// asleep), so without a ceiling here an orphan can sit holding the cross-process +/// sync lock and starve every later trigger. +const TRIGGER_TIMEOUT: Duration = Duration::from_secs(90); + +/// Spawn `meridian ` and wait for it, with `timeout` and +/// `kill_on_drop`. +/// +/// Factored out of [`sync_tasks`] so the button and the background triggers can +/// never drift on the two things that are easy to get wrong and invisible when +/// wrong: the cwd (which picks the credentials, because dotenvy walks up from it) +/// and `kill_on_drop` (without which a timed-out child keeps running and can +/// still mutate the board after the caller gave up). +async fn spawn_meridian_cli( + subcommand: &str, + timeout: Duration, +) -> Result { let bin = crate::install::meridian_bin(); // The cwd picks the credentials, because dotenvy walks up from it: a release // build lands on the canonical ~/.meridian/.env (AZURE_DEVOPS_PAT, JIRA_URL, …), @@ -49,67 +117,100 @@ pub async fn sync_tasks() -> Result { // WHICH binary ran, and from where, are the two facts that make a failure here // legible: a stale installed CLI against a DB the dev daemon migrated ahead // exits non-zero with nothing but `status=Some(1)` in the log otherwise. - tracing::debug!(bin = %bin, cwd = %cwd.display(), "tasks-sync: spawning"); + tracing::debug!(subcommand, bin = %bin, cwd = %cwd.display(), "meridian cli: spawning"); let child = tokio::process::Command::new(&bin) - .arg("tasks-sync") + .arg(subcommand) .current_dir(&cwd) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) // On timeout below, `tokio::time::timeout` drops the output future; without - // this the orphaned `meridian tasks-sync` keeps running (and can still mutate - // the board) after the UI reports a failure. The deleted /api/tasks/sync route - // called child.kill() on its 30s timer — kill_on_drop preserves that contract. + // this the orphaned child keeps running (and can still mutate the board) + // after the caller reports a failure. .kill_on_drop(true) .no_window() .output(); - let output = match tokio::time::timeout(SYNC_TIMEOUT, child).await { + match tokio::time::timeout(timeout, child).await { Err(_) => { // `kill_on_drop` reaps the child here, taking its stderr with it, so // this log is the ONLY record a timeout ever leaves. Emitting a bare - // "tasks-sync timed out" (as it did) makes the two cases that matter - // indistinguishable in a support bundle: a genuinely slow tracker - // sync vs. a `meridian` binary that never got past opening a corrupt - // meridian.db. WHICH binary and WHICH cwd is what separates them — - // the same two facts the spawn/non-zero arms below already log. + // "timed out" makes the two cases that matter indistinguishable in a + // support bundle: a genuinely slow tracker sync vs. a `meridian` + // binary that never got past opening a corrupt meridian.db. tracing::warn!( + subcommand, bin = %bin, cwd = %cwd.display(), - timeout_s = SYNC_TIMEOUT.as_secs(), - "tasks-sync timed out" + timeout_s = timeout.as_secs() as f64, + "meridian cli timed out" ); - return Err(format!( - "tasks-sync timed out after {}s", - SYNC_TIMEOUT.as_secs() - )); + Err(format!( + "{subcommand} timed out after {}s", + timeout.as_secs() + )) } Ok(Err(e)) => { - tracing::warn!(bin = %bin, error = %e, "tasks-sync spawn failed"); - return Err(format!("spawn error: {e}")); + tracing::warn!(subcommand, bin = %bin, error = %e, "meridian cli spawn failed"); + Err(format!("spawn error: {e}")) } - Ok(Ok(o)) => o, - }; - - if output.status.success() { - let detail = String::from_utf8_lossy(&output.stdout).trim().to_string(); - tracing::info!("tasks-sync ok"); - Ok(SyncResult { ok: true, detail }) - } else { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - // Log the CLI's own reason, not just the exit code. `status=Some(1)` alone - // says nothing — the failure is always explained in stderr, and dropping it - // turned a one-line diagnosis into a manual re-run of the subcommand. - tracing::warn!( - status = ?output.status.code(), - bin = %bin, - stderr = %stderr, - "tasks-sync non-zero" - ); - Err(if stderr.is_empty() { - "tasks-sync failed".to_string() - } else { - stderr - }) + Ok(Ok(o)) => Ok(o), } } + +/// Fire off a GATED background sync and return immediately. +/// +/// `reason` names the trigger (`"dashboard_open"`, `"token_connected"`, …) and is +/// logged, so the on-demand schedule is legible in a support bundle - "why did +/// this install sync at 09:14" has an answer. +/// +/// Detached on purpose: the caller is opening a window or finishing a settings +/// save, and must not wait on the network to do it. Every outcome logs at +/// `debug`/`warn` and NOTHING is surfaced to the UI - a best-effort refresh that +/// failed is not a thing to interrupt someone with, and the next trigger retries. +/// +/// Gated, not forced: these fire repeatedly within seconds (open the dashboard, +/// close it, open it again), and a forced fetch each time would recreate exactly +/// the API hammering the removed timer was doing. +pub(crate) fn trigger_background_pm_sync(reason: &'static str) { + spawn_trigger("pm-sync", reason); +} + +/// Fire off a FORCED background sync and return immediately. +/// +/// For the moments where the cache is knowingly wrong and the staleness gate +/// would wrongly skip: a tracker was just connected, so `pm_sync_state` may say +/// "synced 2 minutes ago" from a previous account while `pm_tasks` holds another +/// board's tickets. Same detached, best-effort, never-surfaced discipline as +/// [`trigger_background_pm_sync`]. +pub(crate) fn trigger_background_pm_force_sync(reason: &'static str) { + spawn_trigger("tasks-sync", reason); +} + +/// Shared body of the two triggers: spawn onto the runtime, log the outcome, +/// surface nothing. One place so the two can never disagree about what +/// "best-effort" means. +fn spawn_trigger(subcommand: &'static str, reason: &'static str) { + tauri::async_runtime::spawn(async move { + match spawn_meridian_cli(subcommand, TRIGGER_TIMEOUT).await { + Ok(o) if o.status.success() => { + tracing::debug!(subcommand, reason, "background pm sync finished"); + } + Ok(o) => { + // WARN, not ERROR: a tracker being briefly unreachable is + // ordinary, and the next trigger retries. Carrying stderr means + // a real fault (dead grant, bad JQL) is still diagnosable. + tracing::warn!( + subcommand, + reason, + status = ?o.status.code(), + stderr = %String::from_utf8_lossy(&o.stderr).trim(), + "background pm sync exited non-zero" + ); + } + Err(e) => { + tracing::warn!(subcommand, reason, detail = %e, "background pm sync failed"); + } + } + }); +} diff --git a/tray/src-tauri/src/tray.rs b/tray/src-tauri/src/tray.rs index 58c61b322..0066a1b0c 100644 --- a/tray/src-tauri/src/tray.rs +++ b/tray/src-tauri/src/tray.rs @@ -116,6 +116,16 @@ pub(crate) fn open_native_dashboard(app: &tauri::AppHandle) { open_wizard_window(app); return; } + // ON-DEMAND PM SYNC. The daemon no longer syncs the board on a timer (see + // `commands::tasks`'s header for why that timer was killing OAuth grants), + // so opening the dashboard is one of the moments that has to ask for a + // refresh. Placed AFTER the onboarding gate: a fresh install has no tracker + // to sync and the redirect above returns before reaching here. + // + // Fire-and-forget and gated - the window paints from the cached board + // immediately and never waits on the network, and a re-open seconds later + // no-ops on the staleness gate rather than re-fetching. + crate::commands::tasks::trigger_background_pm_sync("dashboard_open_menu"); crate::commands::system::dismiss_popover(app); if let Some(win) = app.get_webview_window("dashboard") { let _ = win.show(); diff --git a/ui/components/timeline/settings/IntegrationsSection.tsx b/ui/components/timeline/settings/IntegrationsSection.tsx index 76fe3acb5..11e2ac66e 100644 --- a/ui/components/timeline/settings/IntegrationsSection.tsx +++ b/ui/components/timeline/settings/IntegrationsSection.tsx @@ -64,7 +64,7 @@ export function IntegrationsSection({ integrations, onChanged, gate = false, onD Connected to {connected.map(t => t.name).join(', ')}

- Syncing every hour + Kept in sync automatically

From 4a0a2fc4e44a193a978161937c83634460ca3707 Mon Sep 17 00:00:00 2001 From: adityaharishch Date: Fri, 28 Aug 2026 15:03:31 +0530 Subject: [PATCH 27/53] fix(oauth): make the Jira refresh-token exchange crash- and suspend-safe The previous commit removed the timer that made a lost refresh LIKELY. This fixes the exchange itself so a lost response is RECOVERABLE. Atlassian rotates the refresh token on every use, so the exchange has a window where the old token is already dead server-side and we do not yet know its replacement. Nothing recorded that a spend had been attempted, so there was no way to tell "never spent" from "spent, answer lost" - and those need opposite handling. Every lost response destroyed the grant permanently and the user had to re-authenticate by hand. Three mechanisms, all load-bearing: * meridian-oauth/src/refresh_journal.rs - the token about to be spent is written and fsync'd (file AND directory) BEFORE the POST, and cleared only AFTER the new pair is persisted. Recovery is decided by COMPARING TOKENS, not by trusting a flag: journalled == stored means the save never landed, so replay; journalled != stored means it did land and we died before clearing, so the journal is stale. That makes every crash point recoverable and needs no "in progress" boolean that could itself go stale. * A wall-clock deadline on each token POST (flow.rs). Every `.timeout()` and every tokio timer measures `Instant`, which on macOS does not advance while the system sleeps - so a request in flight when the lid closes was not cancelled by its 8s timeout, it hung for the entire suspend. The watchdog still sleeps monotonically (that is the only timer available) but compares the WALL clock on each tick, so nothing fires mid-suspend and it gives up immediately on resume. Abandoning promptly is what leaves enough of the provider's reuse grace to replay. * ensure_fresh replays an unresolved spend with the journalled token. Atlassian honours the previous refresh token for a grace period after rotation, so re-presenting it returns the current pair and recovers the grant. REPLAY_WINDOW_SECS is 8 minutes against a documented 10, leaving margin for NTP skew on wake and for the request's own duration. Two failure modes deliberately chosen: * If the journal cannot be written, the refresh is ABANDONED rather than attempted. Spending a rotating token with no durable record is the unrecoverable state this exists to prevent. * If `store::save` fails after a successful exchange, the journal is KEPT. The new pair is only in memory, so the journal is the only thing that says the stored token may already be spent. The fast path now also checks the journal: a valid access token can coexist with a refresh token already consumed server-side, and returning early on "not expired" would leave that unresolved for up to an hour - long past the grace, turning a late recovery into an impossible one. Escalation no longer false-positives on normal quiet periods ------------------------------------------------------------ note_transient_sync_failure escalated when there had been no successful sync for 6 hours. That was correct under a timer - its own doc said "comfortably longer than any provider's sync interval" - but with on-demand sync a weekend of quiet is normal, so the first two failures after any gap escalated. Opening the dashboard on Monday before Wi-Fi associated raised a red "sync failing" banner on a healthy install, bodied "No successful sync in the last 6 hours" while describing normal use. That was a regression introduced by the previous commit. It now counts CONSECUTIVE failures (threshold 4), held in memory rather than in pm_sync_state: no migration, cannot corrupt a database, and resetting on restart is the safe direction - a fresh process starts at zero, so a wake can never inherit a stale streak. A blocked proxy fails every attempt; a wake-time blip fails once or twice and then works. This deletes the grace-row/epoch-sentinel mechanism, which also fixes a display bug: mark_first_stale_failure stamped last_synced_at back to 1970, and the health check would have rendered that as "last synced 29799360m ago". stamp_sync_error still writes the sentinel on a never-synced provider, so the health check now detects it explicitly. The wake-triggered drafting pass no longer refreshes --------------------------------------------------- worklog_pipeline::run_loop is a clock-aligned HH:03 timer that also runs once at startup, and launchd KeepAlive restarts the daemon on every wake - so the startup pass is wake-triggered by construction, and the previous commit's pre-draft sync made it a wake-triggered token refresh. A new RefreshBoard enum splits them: the startup pass drafts against the cached board, the chosen-hour tick (when the user is far more likely to be present) refreshes first. I stated "no polling" absolutely in the previous commit; that was wrong, and this is the correction. Tests ----- * Both journal crash points, the corrupt-journal path, 0600 permissions, the wall-clock window's clamping, and that a fresh access token cannot hide an unresolved spend. * Escalation: a single blip and a blip after 72h of quiet both stay silent; four consecutive failures escalate with wording that names the evidence rather than a duration; a success resets; streaks do not leak between providers. * the_startup_pass_never_refreshes_and_the_chosen_hour_tick_does pins the pairing source-level, because the wrong value there is silent. * Two pre-existing tests were sharing a provider name and leaking the streak between parallel threads; they now use unique keys. No migration, no DDL, no new table - verified. jira.rs and auto_generate.rs both passed 500 lines and were split. Still not immune: a suspend longer than the reuse grace, landing inside the exchange, remains unrecoverable. Scoped API tokens are the only structurally immune credential and are a separate change. --- meridian-oauth/src/flow.rs | 75 ++- meridian-oauth/src/{jira.rs => jira/mod.rs} | 214 +++++--- meridian-oauth/src/jira/tests.rs | 184 +++++++ meridian-oauth/src/lib.rs | 1 + meridian-oauth/src/refresh_journal.rs | 338 ++++++++++++ src/health/jira.rs | 46 +- src/intelligence/providers/mod.rs | 303 +++-------- .../providers/sync_failure_tests.rs | 514 ++++++------------ src/pm_worklog/auto_generate/mod.rs | 84 ++- src/pm_worklog/auto_generate/sweep.rs | 53 +- src/worklog_pipeline.rs | 26 +- 11 files changed, 1188 insertions(+), 650 deletions(-) rename meridian-oauth/src/{jira.rs => jira/mod.rs} (66%) create mode 100644 meridian-oauth/src/jira/tests.rs create mode 100644 meridian-oauth/src/refresh_journal.rs diff --git a/meridian-oauth/src/flow.rs b/meridian-oauth/src/flow.rs index 6bd3689e1..0968646e5 100644 --- a/meridian-oauth/src/flow.rs +++ b/meridian-oauth/src/flow.rs @@ -255,6 +255,77 @@ fn truncate_body(text: &str) -> String { format!("{} (truncated, {} bytes total)", &text[..end], text.len()) } +/// Wall-clock ceiling for ONE token-endpoint attempt, in seconds. +/// +/// Sits alongside `reqwest`'s own 8 s `.timeout()` rather than replacing it, and +/// the two measure different things on purpose. +const WALL_CLOCK_BUDGET_SECS: i64 = 20; + +/// Run `fut`, abandoning it if the WALL CLOCK advances past `budget_secs`. +/// +/// # Why `reqwest`'s own timeout is not enough +/// +/// Every `.timeout()` in this codebase - and every `tokio::time` primitive - +/// measures `Instant`, which on macOS is `mach_absolute_time` and DOES NOT ADVANCE +/// while the system is asleep. So a request in flight when the lid closes is not +/// cancelled by its 8 s timeout: it hangs for the entire suspend, and on wake the +/// retry goes out with a refresh token that the provider rotated (and stopped +/// honouring) half an hour ago. That is the exact sequence that killed production +/// grants; see [`crate::refresh_journal`]'s header for the measured case. +/// +/// The watchdog below still SLEEPS on the monotonic clock - it has to, that is the +/// only timer available - but every time it wakes it compares the WALL clock. A +/// suspend freezes both clocks, so nothing fires mid-suspend; the instant the +/// machine resumes, the first tick sees the wall clock has jumped and gives up +/// immediately. Abandoning promptly on wake is the whole point: it is what leaves +/// enough of the provider's reuse grace to replay the spend and recover the grant, +/// instead of discovering the loss when the window has already closed. +/// +/// Abandoning is classified TRANSIENT: an unanswered request says nothing about +/// whether the grant is valid, and treating it as terminal is what turned a +/// suspend into "re-authenticate". +async fn with_wall_clock_deadline(budget_secs: i64, fut: F) -> Result +where + F: std::future::Future>, +{ + let started = wall_clock_unix(); + // Boxed so the future can be polled repeatedly across successive `timeout` + // slices: `timeout` takes its future by value, but `&mut Pin>` is + // itself a future, so borrowing it preserves the in-flight request between + // slices instead of restarting it. + let mut fut = Box::pin(fut); + loop { + // The MONOTONIC slice is just a heartbeat, not the deadline. Deliberately + // short so that the wall-clock check below runs promptly after a resume. + match tokio::time::timeout(Duration::from_millis(500), &mut fut).await { + // The request answered. Checked before the clock on purpose: a + // response already in hand must never be discarded, because the + // refresh token it cost has already been spent. + Ok(out) => return out, + Err(_) => { + let elapsed = wall_clock_unix().saturating_sub(started); + if elapsed > budget_secs { + return Err(TokenError::transient(format!( + "token endpoint did not answer within {elapsed}s of wall-clock \ + time (a system suspend, or a stalled connection); abandoning \ + so the spend can be replayed while the provider still \ + honours the previous token" + ))); + } + } + } + } +} + +/// Wall-clock unix seconds. `0` if the clock is before the epoch, which only +/// happens on a badly misconfigured machine and would otherwise panic. +fn wall_clock_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + /// [`post_token`] with bounded retry-with-backoff for transient failures. Only /// [`TokenFailure::Transient`] errors are retried; a terminal rejection returns /// immediately so a genuinely dead token isn't hidden behind seconds of backoff. @@ -272,7 +343,9 @@ async fn post_token_retrying( .map_err(|e| TokenError::transient(format!("building HTTP client: {e}")))?; let mut backoff = Duration::from_millis(400); for attempt in 1..=TOKEN_POST_ATTEMPTS { - match post_token(&client, token_url, body).await { + match with_wall_clock_deadline(WALL_CLOCK_BUDGET_SECS, post_token(&client, token_url, body)) + .await + { Ok(resp) => return Ok(resp), Err(e) if e.is_transient() && attempt < TOKEN_POST_ATTEMPTS => { tracing::warn!( diff --git a/meridian-oauth/src/jira.rs b/meridian-oauth/src/jira/mod.rs similarity index 66% rename from meridian-oauth/src/jira.rs rename to meridian-oauth/src/jira/mod.rs index 198901125..54b147edb 100644 --- a/meridian-oauth/src/jira.rs +++ b/meridian-oauth/src/jira/mod.rs @@ -236,28 +236,55 @@ pub async fn login(client_id: &str, client_secret: &str, port: u16) -> Result Result { let _guard = refresh_lock().lock().await; // intra-process serialisation - // Fast path: a still-fresh token needs neither a refresh nor the file lock. + // Fast path: a still-fresh token with no unresolved spend needs neither a + // refresh nor the file lock. The journal check is part of the condition, not + // an afterthought - a fresh access token can coexist with a spend whose + // outcome was lost, and skipping straight past that would leave the stored + // refresh token dead and only discover it an hour later, outside the grace. let t = store::load("jira")?; - if !t.is_expired(now_unix(), 120) { + if !t.is_expired(now_unix(), 120) && crate::refresh_journal::load("jira").is_none() { return Ok(t); } - // Slow path: enter the cross-process critical section. A peer (a second daemon, - // the tray's in-process refresh) may be rotating the SAME refresh token right - // now. Best-effort — if the lock can't be taken we log and proceed rather than - // turn the lock itself into a new way for Jira auth to fail. + // Slow path: enter the cross-process critical section. A peer (a second + // daemon, the tray's in-process refresh) may be rotating the SAME refresh + // token right now. Best-effort - if the lock cannot be taken we log and + // proceed rather than turn the lock itself into a new way for Jira auth to + // fail. let _flock = match store::lock_provider("jira").await { Ok(g) => Some(g), Err(e) => { @@ -273,17 +300,68 @@ pub async fn ensure_fresh() -> Result { // adopt their token instead of refreshing again with the dead one. This // double-check is what actually breaks the race. let mut t = store::load("jira")?; - if !t.is_expired(now_unix(), 120) { + let pending = reconcile_journal("jira", &t); + if pending.is_none() && !t.is_expired(now_unix(), 120) { tracing::debug!("jira token already refreshed by another process — adopting it"); return Ok(t); } - tracing::debug!("jira OAuth access token expired — refreshing"); - let resp = flow::refresh(&t.client_id, &spec(), &t.refresh_token) + // WHICH token to send. A pending spend means the last attempt's outcome is + // unknown and the stored token may already be consumed; the journalled one is + // the only thing the provider might still honour, so it is re-presented + // verbatim. Otherwise this is an ordinary refresh with the stored token. + let (refresh_token, client_id, replaying) = match &pending { + Some(spend) => { + let age = spend.age_secs(now_unix()); + if spend.within_replay_window(now_unix()) { + tracing::warn!( + age_s = age as f64, + "replaying an unresolved Jira refresh inside the provider's reuse grace" + ); + } else { + // Attempted anyway: it costs one HTTP call, and the alternative is + // a guaranteed re-authentication. Logged as expected-to-fail so it + // does not read as a surprise in a support bundle. + tracing::warn!( + age_s = age as f64, + window_s = crate::refresh_journal::REPLAY_WINDOW_SECS as f64, + "replaying an unresolved Jira refresh PAST the reuse grace - \ + likely to fail; re-authentication may be required" + ); + } + (spend.refresh_token.clone(), spend.client_id.clone(), true) + } + None => { + tracing::debug!("jira OAuth access token expired — refreshing"); + (t.refresh_token.clone(), t.client_id.clone(), false) + } + }; + + // JOURNAL BEFORE SPENDING. If this write fails the refresh is ABANDONED + // rather than attempted: proceeding would spend a rotating token with no + // record of having done so, which is precisely the unrecoverable state this + // whole path exists to prevent. Refusing costs one stale sync cycle; the + // access token in hand is still returned by the error path's caller on the + // next attempt. + if !replaying { + let spend = crate::refresh_journal::PendingSpend { + refresh_token: refresh_token.clone(), + started_at_unix: now_unix(), + client_id: client_id.clone(), + }; + crate::refresh_journal::record_spend("jira", &spend).context( + "could not journal the Jira refresh before spending it — refusing to spend a \ + rotating refresh token without a durable record, since a lost response would \ + then be unrecoverable. Check permissions on ~/.meridian/oauth/.", + )?; + } + + let resp = flow::refresh(&client_id, &spec(), &refresh_token) .await .context( "refreshing Jira OAuth token — re-run `meridian oauth-login jira` if this persists", )?; + t.access_token = resp.access_token; if !resp.refresh_token.is_empty() { t.refresh_token = resp.refresh_token; // Atlassian rotates the refresh token @@ -292,25 +370,61 @@ pub async fn ensure_fresh() -> Result { if !resp.scope.is_empty() { t.scopes = resp.scope; } - // Save the rotated tokens. If save fails (disk full, permissions), log a - // critical error and return the in-memory tokens anyway — the access token - // is valid for ~1h so the current request succeeds. On the next expiry the - // stored (now-invalid) refresh token will cause a 401 and the user must - // re-authenticate. A critical log surfaces this before it happens. + + // PERSIST FIRST, CLEAR SECOND. A crash between the two is harmless: + // `reconcile_journal` compares the journalled token against the stored one, + // sees they differ, and clears the journal then. The reverse order would lose + // the only record of the spend at exactly the wrong moment. match store::save(&t) { - Ok(()) => {} + Ok(()) => { + crate::refresh_journal::clear("jira"); + if replaying { + tracing::info!("recovered the Jira grant by replaying an unresolved refresh"); + } + } Err(e) => { + // The journal is deliberately LEFT IN PLACE. The new pair is only in + // memory, so on the next process the stored refresh token is the old + // one and the journal is what says "that one may already be spent" - + // deleting it here would throw away the recovery path. tracing::error!( error = %e, - "CRITICAL: failed to persist rotated Jira refresh token — access token \ - valid for ~1h but re-authentication required after expiry. Fix \ - permissions at ~/.meridian/oauth/ then re-run `meridian oauth-login jira`." + "CRITICAL: failed to persist rotated Jira refresh token — the access token \ + is valid for ~1h and the refresh journal has been kept so the next attempt \ + can recover. Fix permissions at ~/.meridian/oauth/." ); } } Ok(t) } +/// Decide what an existing journal entry means, and return the spend that still +/// needs resolving (if any). +/// +/// The decision is made by COMPARING TOKENS rather than trusting a flag, which is +/// what makes every crash point recoverable: +/// +/// * journalled == stored -> the save never landed. The outcome is unknown, so +/// this spend must be replayed. +/// * journalled != stored -> the save DID land and we died before clearing. The +/// stored pair is newer and valid; the journal is stale, so clear it. +fn reconcile_journal( + provider: &str, + stored: &OAuthTokens, +) -> Option { + let spend = crate::refresh_journal::load(provider)?; + if spend.refresh_token == stored.refresh_token { + Some(spend) + } else { + tracing::debug!( + provider, + "refresh journal predates the stored token pair — the save landed, clearing it" + ); + crate::refresh_journal::clear(provider); + None + } +} + /// Resolved per-request auth context. OAuth and basic auth differ in BOTH the API /// base (gateway vs site) and the auth header, so call sites go through this. pub enum JiraReqCtx { @@ -370,50 +484,4 @@ impl JiraReqCtx { } #[cfg(test)] -mod tests { - use super::*; - - fn oauth_ctx() -> JiraReqCtx { - JiraReqCtx::OAuth { - token: "tok".into(), - cloud_id: "cloud-xyz".into(), - site_url: "https://acme.atlassian.net".into(), - } - } - - fn basic_ctx() -> JiraReqCtx { - JiraReqCtx::Basic { - base_url: "https://acme.atlassian.net/".into(), - email: "a@b.com".into(), - api_token: "tok".into(), - } - } - - #[test] - fn oauth_api_url_uses_gateway() { - assert_eq!( - oauth_ctx().api_url("/rest/api/3/search/jql"), - "https://api.atlassian.com/ex/jira/cloud-xyz/rest/api/3/search/jql" - ); - } - - #[test] - fn basic_api_url_uses_site_and_trims_slash() { - assert_eq!( - basic_ctx().api_url("/rest/api/3/search/jql"), - "https://acme.atlassian.net/rest/api/3/search/jql" - ); - } - - #[test] - fn browse_url_uses_site_in_both_modes() { - assert_eq!( - oauth_ctx().browse_url("KAN-1"), - "https://acme.atlassian.net/browse/KAN-1" - ); - assert_eq!( - basic_ctx().browse_url("KAN-1"), - "https://acme.atlassian.net/browse/KAN-1" - ); - } -} +mod tests; diff --git a/meridian-oauth/src/jira/tests.rs b/meridian-oauth/src/jira/tests.rs new file mode 100644 index 000000000..d54f440e1 --- /dev/null +++ b/meridian-oauth/src/jira/tests.rs @@ -0,0 +1,184 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +//! Tests for [`super`] - the Atlassian 3LO wiring. +//! +//! Split into its own file on size (`jira.rs` passed the 500-line rule once the +//! refresh journal's crash-point cases landed). Nothing here is a pure unit test +//! of URL building alone: the journal cases below pin the recovery behaviour that +//! decides whether a lost response destroys a user's grant, so they are the most +//! load-bearing tests in this crate. + +use super::*; + +fn tokens(refresh: &str) -> OAuthTokens { + OAuthTokens { + provider: "jira".into(), + client_id: "cid".into(), + access_token: "access".into(), + refresh_token: refresh.into(), + expires_at: 0, + scopes: String::new(), + cloud_id: String::new(), + site_url: String::new(), + } +} + +/// Scratch `HOME` so the journal lands in a temp dir. Serialised through the +/// crate's env guard because `HOME` is process-global and cargo runs tests in +/// parallel threads. +struct ScratchHome { + dir: std::path::PathBuf, + prev: Option, + _guard: std::sync::MutexGuard<'static, ()>, +} + +impl ScratchHome { + fn new(tag: &str) -> Self { + let guard = crate::env_test_guard(); + let dir = std::env::temp_dir().join(format!("meridian_jira_{tag}_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let prev = std::env::var_os("HOME"); + std::env::set_var("HOME", &dir); + Self { + dir, + prev, + _guard: guard, + } + } +} + +impl Drop for ScratchHome { + fn drop(&mut self) { + match self.prev.take() { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + std::fs::remove_dir_all(&self.dir).ok(); + } +} + +fn journal(token: &str) -> crate::refresh_journal::PendingSpend { + crate::refresh_journal::PendingSpend { + refresh_token: token.into(), + started_at_unix: now_unix(), + client_id: "cid".into(), + } +} + +/// CRASH POINT A: died before `store::save` landed. The journalled token still +/// matches the stored one, so the spend's outcome is unknown and MUST be +/// replayed - re-presenting it inside the provider's grace is the only thing +/// that recovers the grant. +#[test] +fn an_unresolved_spend_is_replayed_when_the_save_never_landed() { + let _home = ScratchHome::new("unresolved"); + let stored = tokens("refresh-A"); + crate::refresh_journal::record_spend("jira", &journal("refresh-A")).unwrap(); + + let pending = reconcile_journal("jira", &stored); + assert_eq!( + pending.map(|p| p.refresh_token), + Some("refresh-A".to_string()), + "a journal matching the stored token means the outcome is unknown - replay it" + ); +} + +/// CRASH POINT B: `store::save` landed, then we died before clearing. The +/// stored pair is NEWER than the journal, so there is nothing to recover and +/// replaying would spend a live token for no reason. The journal must be +/// recognised as stale and removed. +#[test] +fn a_journal_older_than_the_stored_pair_is_stale_and_cleared() { + let _home = ScratchHome::new("stale"); + let stored = tokens("refresh-B"); // save landed: B replaced A + crate::refresh_journal::record_spend("jira", &journal("refresh-A")).unwrap(); + + let pending = reconcile_journal("jira", &stored); + assert!( + pending.is_none(), + "the save landed, so nothing needs replaying" + ); + assert!( + crate::refresh_journal::load("jira").is_none(), + "a stale journal must be cleared, not left to trigger a pointless replay \ + on every future refresh" + ); +} + +/// The ordinary case: no journal, nothing to reconcile. +#[test] +fn no_journal_means_nothing_to_reconcile() { + let _home = ScratchHome::new("none"); + assert!(reconcile_journal("jira", &tokens("refresh-A")).is_none()); +} + +/// A pending spend must defeat the fast path even when the access token is +/// still fresh. +/// +/// This is the subtle one, and getting it wrong reintroduces the whole bug: a +/// valid access token can coexist with a refresh token that was already +/// consumed server-side. Returning early on "not expired" would leave that +/// unresolved for up to an hour - long past the provider's reuse grace - and +/// the recovery would then be impossible rather than merely late. +#[test] +fn a_fresh_access_token_does_not_hide_an_unresolved_spend() { + let _home = ScratchHome::new("fastpath"); + let mut fresh = tokens("refresh-A"); + fresh.expires_at = now_unix() + 3600; // comfortably fresh + assert!( + !fresh.is_expired(now_unix(), 120), + "precondition: the access token must look fresh" + ); + + crate::refresh_journal::record_spend("jira", &journal("refresh-A")).unwrap(); + // The fast path's condition, mirrored: freshness alone must NOT be enough. + let would_take_fast_path = + !fresh.is_expired(now_unix(), 120) && crate::refresh_journal::load("jira").is_none(); + assert!( + !would_take_fast_path, + "a fresh access token must not short-circuit past an unresolved refresh spend" + ); +} + +fn oauth_ctx() -> JiraReqCtx { + JiraReqCtx::OAuth { + token: "tok".into(), + cloud_id: "cloud-xyz".into(), + site_url: "https://acme.atlassian.net".into(), + } +} + +fn basic_ctx() -> JiraReqCtx { + JiraReqCtx::Basic { + base_url: "https://acme.atlassian.net/".into(), + email: "a@b.com".into(), + api_token: "tok".into(), + } +} + +#[test] +fn oauth_api_url_uses_gateway() { + assert_eq!( + oauth_ctx().api_url("/rest/api/3/search/jql"), + "https://api.atlassian.com/ex/jira/cloud-xyz/rest/api/3/search/jql" + ); +} + +#[test] +fn basic_api_url_uses_site_and_trims_slash() { + assert_eq!( + basic_ctx().api_url("/rest/api/3/search/jql"), + "https://acme.atlassian.net/rest/api/3/search/jql" + ); +} + +#[test] +fn browse_url_uses_site_in_both_modes() { + assert_eq!( + oauth_ctx().browse_url("KAN-1"), + "https://acme.atlassian.net/browse/KAN-1" + ); + assert_eq!( + basic_ctx().browse_url("KAN-1"), + "https://acme.atlassian.net/browse/KAN-1" + ); +} diff --git a/meridian-oauth/src/lib.rs b/meridian-oauth/src/lib.rs index 46bb9966d..686aa17b3 100644 --- a/meridian-oauth/src/lib.rs +++ b/meridian-oauth/src/lib.rs @@ -28,6 +28,7 @@ pub mod flow; pub mod github; pub mod jira; pub mod pkce; +pub mod refresh_journal; pub mod store; pub mod trello; diff --git a/meridian-oauth/src/refresh_journal.rs b/meridian-oauth/src/refresh_journal.rs new file mode 100644 index 000000000..2b26752eb --- /dev/null +++ b/meridian-oauth/src/refresh_journal.rs @@ -0,0 +1,338 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +//! Crash-safe and suspend-safe record of a refresh-token spend. +//! +//! # The bug this exists to close +//! +//! Atlassian ROTATES the refresh token on every use: a successful refresh +//! returns a new pair and invalidates the one you sent. So the exchange has a +//! window in which the token has already been consumed server-side and we do not +//! yet know the replacement. If the response is lost in that window, the grant is +//! gone forever and the user must re-authenticate by hand. +//! +//! Production users hit this repeatedly. The mechanism, measured on one install: +//! a refresh POST was in flight when the Mac suspended for 28 minutes. Atlassian +//! rotated the token and replied; the reply went nowhere. `reqwest`'s timeout +//! could not cancel the request, because that timeout is `Instant`-based and the +//! monotonic clock does not advance while macOS sleeps. On wake the socket was +//! dead, and the retry went out with the OLD refresh token - by then 28 minutes +//! stale, well outside Atlassian's reuse leeway. `invalid_grant`, terminal, grant +//! dead. +//! +//! Nothing in the old code recorded that a spend had been attempted, so there was +//! no way to distinguish "the token was never consumed" from "it was consumed and +//! we lost the answer" - and those need opposite handling. +//! +//! # The rule that makes recovery possible +//! +//! Atlassian permits the PREVIOUS refresh token to be presented again within a +//! grace period after rotation, returning the current pair rather than an error. +//! That is the recovery mechanism, and it is time-bounded, so the only thing that +//! matters is replaying the spend QUICKLY - which is impossible without knowing a +//! spend happened. Hence this journal. +//! +//! # Ordering, which is the whole correctness argument +//! +//! 1. Journal the token we are about to spend, and `fsync` it. Only then POST. +//! 2. On success: persist the new pair, `fsync`, and only THEN clear the journal. +//! 3. On any indeterminate outcome (transport error, timeout, killed process, +//! suspend), the journal survives and the next attempt replays it. +//! +//! Steps 1 and 2 are deliberately ordered so that every crash point is +//! recoverable, and the recovery is decided by COMPARING TOKENS rather than by +//! trusting a flag: +//! +//! * journalled token == stored token -> the save never happened. The outcome is +//! unknown; replay with the journalled token. +//! * journalled token != stored token -> the save DID happen and we died before +//! clearing. The stored pair is newer and valid; just clear the journal. +//! +//! That comparison is why a crash between step 2's write and its journal-clear is +//! harmless, and why this needs no "in progress" boolean that could itself be +//! stale. +//! +//! # Why a file and not the database +//! +//! The OAuth token store is already a per-provider file (see [`crate::store`]); +//! the journal belongs beside it. It is also deliberately NOT in `meridian.db`: +//! two processes write that database, and coordination state living there is what +//! produced a `SQLITE_IOERR_SHORT_READ` in production when a reader hit it during +//! the daemon's WAL checkpoint. A `0600` file written temp-then-rename cannot +//! corrupt a database, needs no migration, and cannot fail a sync. +//! +//! # Who calls this +//! [`crate::jira::ensure_fresh`], under the same locks that serialise the spend +//! itself ([`crate::store::lock_provider`] plus the in-process mutex). + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// How long after a spend a replay is still worth attempting. +/// +/// Atlassian's documented reuse grace for a rotated refresh token is 10 minutes. +/// This is deliberately SHORTER: the clock we compare against is the wall clock, +/// which can be adjusted by NTP after a wake, and a replay attempted a second +/// after the real deadline is indistinguishable from a genuinely dead grant. 8 +/// minutes keeps a margin for that skew and for the request's own duration. +/// +/// Past this window a replay is still attempted ONCE (it costs one HTTP call and +/// the alternative is a guaranteed re-authentication), but it is expected to fail +/// and is logged as such rather than as a surprise. +pub const REPLAY_WINDOW_SECS: i64 = 8 * 60; + +/// A refresh-token spend that was started and whose outcome may be unknown. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PendingSpend { + /// The refresh token that was sent. Compared against the stored token to + /// decide whether the save landed - see the module header. + pub refresh_token: String, + /// Wall-clock unix seconds at which the POST was issued. + /// + /// WALL clock, not monotonic, on purpose: the entire point is to measure a + /// span that may include a system suspend, and a monotonic clock does not + /// advance across one. This is the same property whose absence let the + /// original 8-second request timeout sleep through a 28-minute suspend. + pub started_at_unix: i64, + /// The client id the spend was made with, so a replay reconstructs the exact + /// same request even if configuration changed in between. + pub client_id: String, +} + +impl PendingSpend { + /// Seconds elapsed since the spend was issued, per the wall clock. Clamped at + /// zero so a backwards clock adjustment cannot produce a negative age that + /// would read as "in the future" and be treated as fresh forever. + pub fn age_secs(&self, now_unix: i64) -> i64 { + (now_unix - self.started_at_unix).max(0) + } + + /// Whether a replay is still inside the window where the provider is expected + /// to honour the previous token. + pub fn within_replay_window(&self, now_unix: i64) -> bool { + self.age_secs(now_unix) <= REPLAY_WINDOW_SECS + } +} + +fn journal_path(provider: &str) -> Result { + // Reuses `store`'s provider validation and directory resolution, so a journal + // path can never escape the oauth dir and never disagrees with where the + // token it describes lives. + let token_path = crate::store::path(provider)?; + let dir = token_path + .parent() + .context("resolving the OAuth directory for the refresh journal")?; + Ok(dir.join(format!(".{provider}.refresh-journal.json"))) +} + +/// Record that `refresh_token` is about to be spent, durably, before the POST. +/// +/// `fsync`s the file AND its directory before returning. Both matter: without the +/// file sync the record can be in the page cache only, and without the directory +/// sync the rename itself can be lost - either way a crash leaves no journal, +/// which is exactly the state this function exists to prevent. +pub fn record_spend(provider: &str, spend: &PendingSpend) -> Result<()> { + let path = journal_path(provider)?; + let dir = path + .parent() + .context("resolving the OAuth directory for the refresh journal")?; + std::fs::create_dir_all(dir) + .with_context(|| format!("creating {} for the refresh journal", dir.display()))?; + let tmp = dir.join(format!(".{provider}.refresh-journal.tmp")); + let json = serde_json::to_vec_pretty(spend).context("serialising the refresh journal")?; + + { + use std::io::Write; + let mut file = open_private(&tmp)?; + file.write_all(&json) + .with_context(|| format!("writing {}", tmp.display()))?; + file.sync_all() + .with_context(|| format!("fsync {}", tmp.display()))?; + } + std::fs::rename(&tmp, &path) + .with_context(|| format!("renaming {} -> {}", tmp.display(), path.display()))?; + sync_dir(dir); + Ok(()) +} + +/// Read the pending spend, if any. A malformed journal is treated as ABSENT and +/// removed rather than surfaced as an error: it cannot be acted on, and failing +/// the refresh over an unreadable side-file would turn a recovery aid into a new +/// way for auth to break. +pub fn load(provider: &str) -> Option { + let path = journal_path(provider).ok()?; + let raw = std::fs::read_to_string(&path).ok()?; + match serde_json::from_str(&raw) { + Ok(spend) => Some(spend), + Err(e) => { + tracing::warn!( + provider, + error = %e, + "refresh journal is unreadable - discarding it and refreshing normally" + ); + let _ = std::fs::remove_file(&path); + None + } + } +} + +/// Clear the journal once the new pair is durably stored. +/// +/// Best-effort by design: the new tokens are already persisted at this point, so +/// a leftover journal is harmless - the next `ensure_fresh` compares its token +/// against the stored one, sees they differ, and clears it then. Failing the +/// refresh here would discard a SUCCESSFUL exchange. +pub fn clear(provider: &str) { + if let Ok(path) = journal_path(provider) { + match std::fs::remove_file(&path) { + Ok(()) => tracing::debug!(provider, "refresh journal cleared"), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => tracing::warn!( + provider, + error = %e, + "could not clear the refresh journal - the next refresh will reconcile it" + ), + } + } +} + +/// Create/truncate a file that only the owner can read. The mode is set AT OPEN +/// on Unix rather than afterwards, so there is no window in which the file exists +/// world-readable while holding a refresh token. +fn open_private(path: &std::path::Path) -> Result { + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + opts.open(path) + .with_context(|| format!("creating {}", path.display())) +} + +/// `fsync` a directory so a rename into it is durable. Unix-only and +/// best-effort: on Windows a directory handle cannot be opened this way, and +/// there `rename` over an existing file is already ordered by the filesystem. +fn sync_dir(dir: &std::path::Path) { + #[cfg(unix)] + if let Ok(handle) = std::fs::File::open(dir) { + if let Err(e) = handle.sync_all() { + tracing::debug!(dir = %dir.display(), error = %e, "directory fsync failed"); + } + } + #[cfg(not(unix))] + let _ = dir; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spend(token: &str, started_at_unix: i64) -> PendingSpend { + PendingSpend { + refresh_token: token.into(), + started_at_unix, + client_id: "cid".into(), + } + } + + struct ScratchHome { + dir: std::path::PathBuf, + prev: Option, + _guard: std::sync::MutexGuard<'static, ()>, + } + + impl ScratchHome { + fn new(tag: &str) -> Self { + let guard = crate::env_test_guard(); + let dir = + std::env::temp_dir().join(format!("meridian_journal_{tag}_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let prev = std::env::var_os("HOME"); + std::env::set_var("HOME", &dir); + Self { + dir, + prev, + _guard: guard, + } + } + } + + impl Drop for ScratchHome { + fn drop(&mut self) { + match self.prev.take() { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + std::fs::remove_dir_all(&self.dir).ok(); + } + } + + #[test] + fn a_recorded_spend_survives_and_round_trips() { + let _home = ScratchHome::new("roundtrip"); + assert!(load("jira").is_none(), "no journal before recording"); + let s = spend("old-refresh", 1_000); + record_spend("jira", &s).unwrap(); + assert_eq!(load("jira").as_ref(), Some(&s)); + clear("jira"); + assert!( + load("jira").is_none(), + "cleared journal must read as absent" + ); + } + + /// The journal holds a refresh token, so it must never be world-readable - + /// same requirement as the token file itself. + #[cfg(unix)] + #[test] + fn the_journal_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let _home = ScratchHome::new("perms"); + record_spend("jira", &spend("t", 1)).unwrap(); + let path = journal_path("jira").unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "journal must be 0600, got {mode:o}"); + } + + /// A garbage journal must not fail the refresh. It is discarded, so the + /// caller proceeds with a normal refresh rather than erroring on a side-file. + #[test] + fn a_corrupt_journal_reads_as_absent_and_is_removed() { + let _home = ScratchHome::new("corrupt"); + record_spend("jira", &spend("t", 1)).unwrap(); + let path = journal_path("jira").unwrap(); + std::fs::write(&path, b"{not json").unwrap(); + assert!( + load("jira").is_none(), + "a corrupt journal must read as absent" + ); + assert!( + !path.exists(), + "a corrupt journal must be removed, not left to be re-read forever" + ); + } + + /// The replay window is measured on the WALL clock so it spans a suspend. + #[test] + fn the_replay_window_is_bounded_and_clamped() { + let s = spend("t", 1_000); + assert!( + s.within_replay_window(1_000), + "a spend just issued is in window" + ); + assert!( + s.within_replay_window(1_000 + REPLAY_WINDOW_SECS), + "the boundary itself is in window" + ); + assert!( + !s.within_replay_window(1_000 + REPLAY_WINDOW_SECS + 1), + "one second past the window is out" + ); + // A backwards clock jump must not read as "issued in the future", which + // would make the spend look permanently fresh and replay forever. + assert_eq!(s.age_secs(500), 0, "age is clamped at zero"); + assert!(s.within_replay_window(500)); + } +} diff --git a/src/health/jira.rs b/src/health/jira.rs index fa35db6d5..b8b3d29eb 100644 --- a/src/health/jira.rs +++ b/src/health/jira.rs @@ -145,8 +145,15 @@ async fn classify_auth(send: reqwest::Result) -> Check { /// failure warns and carries the provider's own message; anything else reports /// elapsed time as context only, never as a fault. async fn sync_freshness(pool: &SqlitePool) -> Check { - match sqlx::query_as::<_, (Option, Option)>( - "SELECT (julianday('now') - julianday(last_synced_at)) * 86400.0, last_error + // `last_synced_at` is NOT always a real success timestamp: `stamp_sync_error` + // inserts the epoch sentinel when it creates a row for a provider that has + // never synced. Reporting an age off that renders as "last synced 29799360m + // ago", so the sentinel is detected and reported as never-synced instead of + // being arithmetic'd into a nonsense duration. + match sqlx::query_as::<_, (Option, Option, Option)>( + "SELECT (julianday('now') - julianday(last_synced_at)) * 86400.0, + last_error, + last_synced_at FROM pm_sync_state WHERE provider = 'jira'", ) .fetch_optional(pool) @@ -155,19 +162,23 @@ async fn sync_freshness(pool: &SqlitePool) -> Check { // A recorded failure is the ONLY thing that warns. `last_error` is set by // the provider itself, so the text is the real cause rather than this // check's guess at one. - Ok(Some((_, Some(err)))) if !err.trim().is_empty() => { + Ok(Some((_, Some(err), _))) if !err.trim().is_empty() => { Check::warn("ticket sync", "L3", format!("last sync failed: {err}")).with_remedy( "check the auth row above; a sync retries on the next on-demand trigger", ) } + // The epoch sentinel means "a row exists but no sync ever succeeded". + Ok(Some((_, _, Some(stamp)))) if stamp.starts_with("1970") => { + Check::info("ticket sync", "L3", "no successful Jira sync recorded yet") + } // Elapsed time as context. An old cache is normal when nothing asked for a sync. - Ok(Some((Some(age), _))) => Check::ok( + Ok(Some((Some(age), _, _))) => Check::ok( "ticket sync", "L3", format!("last synced {:.0}m ago, no errors", age / 60.0), ), // A row with neither a parseable timestamp nor an error: nothing to report. - Ok(Some((None, _))) => { + Ok(Some((None, _, _))) => { Check::info("ticket sync", "L3", "no successful Jira sync recorded yet") } Ok(None) => Check::info("ticket sync", "L3", "no Jira sync recorded yet"), @@ -297,4 +308,29 @@ mod tests { "never-synced must be info, got {check:?}" ); } + + /// The epoch sentinel must not be rendered as an age. + /// + /// `stamp_sync_error` inserts `1970-01-01T00:00:00Z` when it creates a row for + /// a provider that never synced. Doing the arithmetic on that produced "last + /// synced 29799360m ago" - a real string this would have shown a user. + #[tokio::test] + async fn the_epoch_sentinel_is_not_reported_as_an_age() { + let pool = db().await; + sqlx::query( + "INSERT INTO pm_sync_state (provider, last_synced_at, last_error) + VALUES ('jira', '1970-01-01T00:00:00Z', NULL)", + ) + .execute(&pool) + .await + .unwrap(); + + let check = sync_freshness(&pool).await; + assert_eq!(check.severity, Severity::Info, "got {check:?}"); + assert!( + !check.detail.contains('m'), + "the sentinel must not be turned into a minutes figure, got {:?}", + check.detail + ); + } } diff --git a/src/intelligence/providers/mod.rs b/src/intelligence/providers/mod.rs index 1e71783ed..b8ab01fb1 100644 --- a/src/intelligence/providers/mod.rs +++ b/src/intelligence/providers/mod.rs @@ -91,167 +91,74 @@ pub async fn stamp_sync_error_with_remedy( Ok(()) } -/// How stale the last SUCCESSFUL sync may get before a run of otherwise- -/// suppressed transient failures is escalated to a user-facing notice. +/// How many CONSECUTIVE failed sync attempts a provider gets before its +/// otherwise-suppressed transient failures escalate to a user-facing notice. /// -/// Comfortably longer than any provider's sync interval, so an ordinary flaky -/// hour never trips it — but short enough that a persistently blocked provider -/// surfaces the same working day. -const TRANSIENT_ESCALATION_HOURS: i64 = 6; - -/// Record a transient (retryable) sync failure. -/// -/// Normally raises NOTHING — [`http::SyncFault::Retry`] exists precisely so a -/// network blip stops producing a "check your credentials" banner for -/// credentials that are fine. -/// -/// But silence has to be BOUNDED. A provider blocked persistently (corporate -/// proxy, TLS interception, a firewall rule) is unreachable on every tick -/// forever, and suppressing that outright would leave the user's board silently -/// going stale with no signal at all — strictly worse than the misleading banner -/// this change removes. So the failure escalates to a normal sync notice once -/// the provider has not synced successfully within -/// [`TRANSIENT_ESCALATION_HOURS`]. -/// -/// **A provider that has NEVER synced needs its own handling**, because it has -/// no `pm_sync_state` row at all, so an age test alone would no-op forever on -/// exactly the installs that most need it — someone who just connected and whose -/// network blocks the provider outright. -/// -/// It gets ONE retry rather than escalating on the first failure. Escalating -/// immediately would reintroduce the very thing this change removes, just with -/// connectivity wording instead of credentials wording: connect a tracker, have -/// the first attempt land on an ordinary blip (a DNS hiccup, a proxy handshake, -/// a laptop still waking up) and a "sync failing" banner appears seconds later. -/// So the first failure writes the row and stays silent; the next one sees the -/// row with an epoch `last_synced_at`, which is not recent, and escalates. One -/// sync interval of grace, and the persistently-blocked case still surfaces. -/// -/// **This same one-retry grace applies to a provider that has synced -/// successfully before**, not just a brand-new one - see the `is_sentinel` -/// branch in [`note_transient_sync_failure`]. Without it, a provider whose -/// `last_synced_at` goes stale because the DAEMON WAS ASLEEP (laptop closed -/// overnight, a longer Wi-Fi/VPN drop) rather than because it kept failing -/// escalates on its very first retry after waking - the exact false-positive -/// this module exists to remove, just triggered by a gap in *time* instead of -/// a gap in connectivity. The grace row and this ONE mechanism (the epoch -/// sentinel) cover both cases identically, so the escalation check cannot tell -/// them apart and does not need to. +/// This replaced a "no successful sync in the last 6 hours" rule. That rule was +/// correct when written: sync ran on a timer every few minutes, so six hours +/// without a success reliably meant something was broken - its own doc comment +/// said "comfortably longer than any provider's sync interval". /// -/// The remedy points at connectivity, NOT credentials: by construction we only -/// reach here for failures that are not the user's token. +/// Sync is on-demand now (see [`crate::intelligence::run_pm_sync`]) and that +/// premise is gone. A machine that slept all weekend, or a user who has not +/// opened the dashboard since Friday, has no successful sync for DAYS and nothing +/// is wrong. Under the old rule the first two failures after any quiet period +/// escalated, so opening the dashboard on Monday before Wi-Fi associated raised a +/// red "sync failing" banner on a healthy install - with a body reading "No +/// successful sync in the last 6 hours" while describing normal use. /// -/// Write the epoch-sentinel grace row for a provider that has never synced. -/// -/// **Deliberately does NOT write `last_error`** (unlike the escalating write -/// in [`stamp_sync_error_with_remedy`]): `meridian_core::readers::integrations::sync_errors` -/// reads `last_error IS NOT NULL` to drive the per-provider "Sync failed" -/// badge on the Integrations page, independent of whether a system-wide -/// notice was raised. Setting it here would show that badge during a period -/// this function is specifically trying to keep quiet - `detail` is still -/// logged (below) so the attempt is not lost from telemetry, just kept out of -/// the persisted, user-visible column. -/// -/// # Why `ON CONFLICT DO NOTHING` -/// The caller's `has_row` check is a SEPARATE statement from this insert, and -/// `pm_sync_state.provider` is a PRIMARY KEY - so anything that can run two -/// sync passes for one provider concurrently can have both observe "no row" -/// and both insert. That is not hypothetical here: `meridian ticket-update` -/// opens its OWN pool to the same `meridian.db` and calls -/// `intelligence::run_pm_force_sync` (`src/main.rs`) while the daemon's poll -/// loop is running `run_pm_sync` against the same file, so the two racers are -/// separate PROCESSES and no in-process lock would cover them. -/// -/// Without the clause the loser gets `UNIQUE constraint failed`, which -/// [`record_sync_failure`] swallows by design - so the grace row would be -/// silently lost, the next failure would take the same branch again, and the -/// escalation clock would never start. That is the exact failure this whole -/// module exists to prevent, reintroduced by a race. +/// Consecutive failures are the honest evidence: a blocked proxy or a dead route +/// fails EVERY attempt, whereas a wake-time blip fails once or twice then works. +/// Elapsed quiet carries no signal at all any more. +const TRANSIENT_ESCALATION_ATTEMPTS: u32 = 4; + +/// Consecutive transient failures per provider, for the CURRENT PROCESS only. /// -/// Doing nothing on conflict is the correct resolution rather than a -/// compromise: the row the winner wrote carries the same epoch sentinel this -/// one would have written, so both racers leave the state they intended. +/// In memory rather than in `pm_sync_state`, deliberately: /// -/// Extracted from [`note_transient_sync_failure`] to give the conflict -/// behaviour a testable seam - the race itself has no deterministic hook, but -/// "inserting over an existing row is a no-op, not an error" does. -async fn insert_grace_row(pool: &SqlitePool, provider: &str, detail: &str) -> Result<()> { - tracing::debug!( - provider, - detail, - "grace row: recording the attempt without a user-visible error" - ); - sqlx::query( - "INSERT INTO pm_sync_state (provider, last_synced_at, last_error) - VALUES (?, '1970-01-01T00:00:00Z', NULL) - ON CONFLICT(provider) DO NOTHING", - ) - .bind(provider) - .execute(pool) - .await - .context("recording the first transient failure for a never-synced provider")?; - Ok(()) -} +/// * No migration, and it cannot corrupt a database - the same reasoning that put +/// the sync dedup lock and the OAuth refresh journal outside `meridian.db`. +/// * Resetting on restart is the SAFE direction. launchd `KeepAlive` restarts the +/// daemon on every wake, so a fresh process starts at zero and can never +/// inherit a stale streak and escalate on its first blip. The cost is that a +/// persistent block takes a few attempts within one session to surface, which +/// is the trade we want: quiet by default, loud only on real evidence. +static TRANSIENT_STREAKS: std::sync::Mutex>> = + std::sync::Mutex::new(None); -/// Downgrade a STALE-BUT-PREVIOUSLY-SUCCESSFUL provider's `last_synced_at` to -/// the epoch sentinel, marking its one retry of grace as used - WITHOUT -/// writing `last_error`, for the same reason [`insert_grace_row`] doesn't (see -/// its doc comment). Mirrors that function's shape but is an UPDATE, not an -/// INSERT: the row already exists here, carrying a real (if old) past-success -/// timestamp that this call intentionally overwrites, so the NEXT failure's -/// `is_sentinel` check reads it identically to a never-synced provider's grace -/// row and escalates. -async fn mark_first_stale_failure(pool: &SqlitePool, provider: &str, detail: &str) -> Result<()> { - tracing::debug!( - provider, - detail, - "first transient failure since a genuine past success - staying quiet until the next attempt" - ); - // The WHERE clause re-checks, inside the write, the same three conditions - // the caller checked in a SEPARATE statement: still stale, not already the - // sentinel, and carrying no error. - // - // For the same reason `insert_grace_row` needs `ON CONFLICT DO NOTHING`: - // `meridian ticket-update` opens its own pool to this file and runs - // `run_pm_force_sync` while the daemon's poll loop runs `run_pm_sync`, so - // the racers are separate PROCESSES and no in-process lock covers them. - // That path was guarded for the INSERT and left open for this UPDATE. - // - // The damage an unconditional write does is not a lost grace tick (both - // racers intend the same sentinel) but the opposite: a sync that SUCCEEDS - // between the caller's read and this statement has its fresh - // `last_synced_at` stamped back to the epoch. The provider then reads as - // never-recently-synced, and the next failure escalates a user-visible - // notice for a tracker that is working. A single conditional statement is - // atomic in SQLite, so the success either lands before this (and the - // `last_synced_at` guard makes this a no-op) or after it (and it wins). - let updated = sqlx::query( - "UPDATE pm_sync_state SET last_synced_at = '1970-01-01T00:00:00Z' - WHERE provider = ? - AND last_error IS NULL - AND last_synced_at != '1970-01-01T00:00:00Z' - AND last_synced_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ?)", - ) - .bind(provider) - .bind(format!("-{TRANSIENT_ESCALATION_HOURS} hours")) - .execute(pool) - .await - .context("recording a resumed provider's first quiet failure since its last success")? - .rows_affected(); +/// Increment and return this provider's consecutive-failure count. +fn bump_transient_streak(provider: &str) -> u32 { + let mut guard = TRANSIENT_STREAKS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let map = guard.get_or_insert_with(std::collections::HashMap::new); + let counter = map.entry(provider.to_string()).or_insert(0); + *counter = counter.saturating_add(1); + *counter +} - if updated == 0 { - // Something changed under us between the read and the write. Staying - // quiet is still the right answer for THIS attempt - the caller already - // decided not to escalate - but say so, because the alternative reading - // ("the write silently did nothing") is the bug this guard introduces - // if it is ever wrong. - tracing::debug!( - provider, - "grace write skipped - the row stopped being a clean stale transition \ - (a concurrent sync succeeded, or another pass already recorded it)" - ); +/// Reset this provider's consecutive-failure count after a successful sync. +/// +/// A success is the ONLY thing that clears it, which is what makes the count mean +/// "failures in a row" rather than "failures ever". +pub(crate) fn reset_transient_streak(provider: &str) { + let mut guard = TRANSIENT_STREAKS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(map) = guard.as_mut() { + map.remove(provider); } - Ok(()) +} + +/// Test-only: force a provider's streak so escalation can be exercised without +/// calling the recorder N times. +#[cfg(test)] +pub(crate) fn set_transient_streak_for_test(provider: &str, value: u32) { + let mut guard = TRANSIENT_STREAKS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let map = guard.get_or_insert_with(std::collections::HashMap::new); + map.insert(provider.to_string(), value); } /// Returns whether it escalated. @@ -264,77 +171,37 @@ pub async fn note_transient_sync_failure( provider: &str, detail: &str, ) -> Result { - let threshold = format!("-{TRANSIENT_ESCALATION_HOURS} hours"); - let (has_row, recently_synced, is_sentinel, has_error): (i64, i64, i64, i64) = sqlx::query_as( - "SELECT - EXISTS(SELECT 1 FROM pm_sync_state WHERE provider = ?), - EXISTS( - SELECT 1 FROM pm_sync_state - WHERE provider = ? - AND last_synced_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', ?) - ), - EXISTS( - SELECT 1 FROM pm_sync_state - WHERE provider = ? AND last_synced_at = '1970-01-01T00:00:00Z' - ), - EXISTS( - SELECT 1 FROM pm_sync_state WHERE provider = ? AND last_error IS NOT NULL - )", - ) - .bind(provider) - .bind(provider) - .bind(&threshold) - .bind(provider) - .bind(provider) - .fetch_one(pool) - .await - .context("checking sync recency for transient-failure escalation")?; - - if recently_synced != 0 { - tracing::Span::current().record("escalated", false); - return Ok(false); - } + let streak = bump_transient_streak(provider); + let span = tracing::Span::current(); + span.record("streak", streak); - if has_row == 0 { - // First-ever failure on a provider that has never synced. Record the - // attempt so the NEXT one escalates, but raise nothing: a tracker - // connected seconds ago that hits one blip must not immediately show a - // fault. The epoch sentinel is deliberate - it is not a recent sync, so - // the next failure takes the escalation branch below. - insert_grace_row(pool, provider, detail).await?; + if streak < TRANSIENT_ESCALATION_ATTEMPTS { + // Silence, which is the normal outcome. `SyncFault::Retry` exists so a + // network blip does not produce a banner for credentials that are fine, + // and a wake-time blip is the single most common instance of one. tracing::debug!( provider, - "first transient failure on a never-synced provider - staying quiet until the next attempt" + streak, + needed = TRANSIENT_ESCALATION_ATTEMPTS, + "transient sync failure - staying quiet until the streak is real" ); - tracing::Span::current().record("escalated", false); - return Ok(false); - } - - // Not recently synced, and the provider HAS synced before. Give it the - // SAME one retry of grace a never-synced provider gets, unless there is - // already something to weigh against staying quiet: `is_sentinel` covers - // "the grace tick already ran" (this branch set `last_synced_at` to the - // sentinel with no `last_error`, and a SECOND consecutive failure now - // finds it), and `has_error` covers "a fault is already recorded here" - // (a terminal failure elsewhere already wrote `last_error` and raised its - // own notice - nothing is gained by staying quiet on top of that). Only a - // row that has synced before, has since gone stale, and carries neither - // signal - a clean transition into staleness - gets the grace write. - if has_error == 0 && is_sentinel == 0 { - mark_first_stale_failure(pool, provider, detail).await?; - tracing::Span::current().record("escalated", false); + span.record("escalated", false); return Ok(false); } + // Silence has to be BOUNDED. A provider blocked persistently (corporate + // proxy, TLS interception, a firewall rule) fails every single attempt + // forever, and suppressing that outright would leave the board silently going + // stale with no signal at all - strictly worse than a misleading banner. tracing::warn!( provider, - hours = TRANSIENT_ESCALATION_HOURS, - "provider unreachable with no recent successful sync - escalating to a notice" + streak, + "provider unreachable on {streak} consecutive attempts - escalating to a notice" ); - // The title already names the provider, so the body only has to say what is - // wrong and carry the cause. - let msg = - format!("No successful sync in the last {TRANSIENT_ESCALATION_HOURS} hours - {detail}"); + // The message names the evidence, not a duration. The old wording ("No + // successful sync in the last 6 hours") described normal on-demand operation + // and so read as a false alarm even when the fault was real. + let msg = format!("Unreachable on {streak} consecutive sync attempts - {detail}"); stamp_sync_error_with_remedy( pool, provider, @@ -342,7 +209,7 @@ pub async fn note_transient_sync_failure( Some("Check your internet connection, VPN, or proxy settings"), ) .await?; - tracing::Span::current().record("escalated", true); + span.record("escalated", true); Ok(true) } @@ -446,6 +313,12 @@ pub async fn record_sync_failure( /// Clear the last error for a provider after a successful sync. pub async fn clear_sync_error(pool: &SqlitePool, provider: &str) -> Result<()> { + // A success is the ONLY thing that resets the consecutive-failure streak, + // which is what makes that count mean "failures in a row" rather than + // "failures ever". Reset FIRST: if the DB write below fails, the streak is + // still correct (the sync did succeed), whereas a streak left standing after + // a success would escalate on the next single blip. + reset_transient_streak(provider); sqlx::query("UPDATE pm_sync_state SET last_error = NULL WHERE provider = ?") .bind(provider) .execute(pool) diff --git a/src/intelligence/providers/sync_failure_tests.rs b/src/intelligence/providers/sync_failure_tests.rs index f1af81986..9676b0a58 100644 --- a/src/intelligence/providers/sync_failure_tests.rs +++ b/src/intelligence/providers/sync_failure_tests.rs @@ -44,308 +44,197 @@ async fn notice(pool: &SqlitePool, id: &str) -> Option<(String, String, Option,) = - sqlx::query_as("SELECT last_error FROM pm_sync_state WHERE provider = 'jira'") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!( - last_error, None, - "the grace attempt must not make the Integrations page show \"Sync failed\"" + !detail.contains("6 hours"), + "the old duration wording must not come back - got {detail:?}" ); - - let second = note_transient_sync_failure(&pool, "jira", "dns error") - .await - .unwrap(); assert!( - second, - "a second consecutive failure means it is not just a blip" + remedy.is_some_and(|r| r.to_lowercase().contains("connection")), + "a connectivity fault must point at connectivity" ); - assert!(notice(&pool, "pm.jira").await.is_some()); } -/// Grace is per-gap, not a one-time allowance for the provider's lifetime: a -/// genuine intervening success must reset it, or a provider that recovers and -/// later fails again would skip straight to escalation. +/// A SUCCESS is the only thing that resets the streak. Without this, failures +/// accumulate across hours of healthy operation and the Nth blip of the day +/// escalates for no reason. #[tokio::test] -async fn a_real_success_between_gaps_resets_the_grace() { +async fn a_success_resets_the_streak() { let pool = make_db().await; - set_last_sync(&pool, "jira", "-9 hours").await; + fresh_streak("resets"); + set_last_sync(&pool, "resets", "-2 minutes").await; - assert!( - !note_transient_sync_failure(&pool, "jira", "dns error") + for _ in 1..TRANSIENT_ESCALATION_ATTEMPTS { + assert!(!note_transient_sync_failure(&pool, "resets", "blip") .await - .unwrap(), - "first failure after the first gap: grace" - ); - - // A real success lands - e.g. the network came back for a while. - set_last_sync(&pool, "jira", "-0 hours").await; + .unwrap()); + } + // One good sync in between wipes the slate. + clear_sync_error(&pool, "resets").await.unwrap(); - // Then a second, later gap starts. - set_last_sync(&pool, "jira", "-9 hours").await; assert!( - !note_transient_sync_failure(&pool, "jira", "dns error") + !note_transient_sync_failure(&pool, "resets", "blip") .await .unwrap(), - "first failure after the SECOND gap must get grace again, not skip to escalation" + "after a success the count restarts, so this is failure #1 and stays silent" ); -} - -/// The grace-row insert races a concurrent one across PROCESSES: `meridian -/// ticket-update` force-syncs through its own pool on the same `meridian.db` -/// while the daemon polls. Both can observe "no row" and both insert against -/// the `provider` PRIMARY KEY. -/// -/// Without `ON CONFLICT DO NOTHING` the loser gets a UNIQUE violation, which -/// `record_sync_failure` swallows by design - so the grace row vanishes, the -/// next failure takes the never-synced branch again, and the escalation clock -/// never starts. A provider blocked forever would then stay silent forever, -/// which is the failure mode this module exists to prevent. -/// -/// The race has no deterministic hook, so this pins the property that makes -/// the race survivable: inserting over an existing row is a no-op, not an -/// error, and the first writer's row is left intact. -#[tokio::test] -async fn a_racing_grace_row_insert_is_a_no_op_not_an_error() { - let pool = make_db().await; - - insert_grace_row(&pool, "github", "first writer") - .await - .unwrap(); - insert_grace_row(&pool, "github", "second writer") - .await - .expect("a concurrent grace-row insert must not error"); - - let (count,): (i64,) = - sqlx::query_as("SELECT COUNT(*) FROM pm_sync_state WHERE provider = 'github'") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(count, 1, "the racing insert must not duplicate the row"); - - let (last, detail): (String, Option) = sqlx::query_as( - "SELECT last_synced_at, last_error FROM pm_sync_state WHERE provider = 'github'", - ) - .fetch_one(&pool) - .await - .unwrap(); assert!( - last.starts_with("1970"), - "the sentinel must survive the race, or escalation stalls: {last}" - ); - assert_eq!( - detail, None, - "a grace row must never carry a user-visible last_error, from either writer" + notice(&pool, "pm.resets").await.is_none(), + "no notice may survive a successful sync" ); } -/// A provider that HAS synced before must still escalate normally after a -/// grace row lands - i.e. the conflict-tolerant insert did not change the -/// contract the escalation clock reads. +/// The streak is per provider. A flaky GitHub must not push Jira toward a notice. #[tokio::test] -async fn a_grace_row_from_a_race_still_escalates_on_the_next_failure() { +async fn streaks_do_not_leak_between_providers() { let pool = make_db().await; - insert_grace_row(&pool, "github", "first writer") - .await - .unwrap(); + fresh_streak("leak_a"); + fresh_streak("leak_b"); - let escalated = note_transient_sync_failure(&pool, "github", "dns error") - .await - .unwrap(); + for _ in 1..TRANSIENT_ESCALATION_ATTEMPTS { + assert!(!note_transient_sync_failure(&pool, "leak_a", "blip") + .await + .unwrap()); + } assert!( - escalated, - "the epoch sentinel is not a recent sync, so the next failure must surface" + !note_transient_sync_failure(&pool, "leak_b", "blip") + .await + .unwrap(), + "provider b is on its FIRST failure and must be unaffected by a's streak" ); - assert!(notice(&pool, "pm.github").await.is_some()); } -/// The grace is exactly one retry, not an open-ended reprieve: the row the -/// first failure writes must carry the epoch sentinel, or it would read as -/// a recent sync and suppress escalation forever. +/// A never-synced provider (no `pm_sync_state` row at all) follows the same rule. +/// +/// It used to need its own branch, because an age test on a missing row no-opped +/// forever - exactly on the installs that most needed it, someone who just +/// connected and whose network blocks the provider. Counting attempts removes +/// that special case entirely, and this pins that it really is gone. #[tokio::test] -async fn the_first_failure_row_does_not_look_like_a_sync() { +async fn a_never_synced_provider_escalates_on_the_same_streak() { let pool = make_db().await; - note_transient_sync_failure(&pool, "github", "dns error") - .await - .unwrap(); + fresh_streak("virgin"); + // Deliberately NO set_last_sync: there is no row. - let (last,): (String,) = - sqlx::query_as("SELECT last_synced_at FROM pm_sync_state WHERE provider = 'github'") - .fetch_one(&pool) + for _ in 1..TRANSIENT_ESCALATION_ATTEMPTS { + assert!(!note_transient_sync_failure(&pool, "virgin", "dns error") .await - .unwrap(); + .unwrap()); + } assert!( - last.starts_with("1970"), - "the grace row must not count as a sync: {last}" + note_transient_sync_failure(&pool, "virgin", "dns error") + .await + .unwrap(), + "a provider that never synced must still escalate on a real streak" ); } -/// A row left by an earlier TERMINAL failure carries the epoch sentinel -/// (`stamp_sync_error` inserts `1970-01-01`), which is a row but not a -/// success. It must not be mistaken for one. +/// The test seam exists and drives escalation, so a future test does not have to +/// know the threshold's value to exercise the loud path. #[tokio::test] -async fn an_epoch_sentinel_row_is_not_a_recent_success() { +async fn the_streak_can_be_forced_for_tests() { let pool = make_db().await; - stamp_sync_error(&pool, "github", "bad credentials") - .await - .unwrap(); - - let escalated = note_transient_sync_failure(&pool, "github", "dns error") - .await - .unwrap(); - - assert!(escalated, "an epoch last_synced_at is not a success"); -} - -/// The window itself, from both sides. -#[tokio::test] -async fn the_escalation_window_holds_on_both_sides() { - let pool = make_db().await; - - set_last_sync(&pool, "jira", "-1 hours").await; - assert!( - !note_transient_sync_failure(&pool, "jira", "blip") - .await - .unwrap(), - "inside the window must stay silent" - ); - - set_last_sync(&pool, "jira", "-7 hours").await; - assert!( - !note_transient_sync_failure(&pool, "jira", "blip") - .await - .unwrap(), - "the first failure outside the window still gets one retry of grace" - ); + set_transient_streak_for_test("forced", TRANSIENT_ESCALATION_ATTEMPTS - 1); assert!( - note_transient_sync_failure(&pool, "jira", "blip") + note_transient_sync_failure(&pool, "forced", "blip") .await .unwrap(), - "the second consecutive failure outside the window must escalate" + "one more failure on top of a forced near-threshold streak must escalate" ); + reset_transient_streak("forced"); } /// The escalation must self-heal: once the provider is reachable again the @@ -353,10 +242,10 @@ async fn the_escalation_window_holds_on_both_sides() { #[tokio::test] async fn a_successful_sync_clears_an_escalated_notice() { let pool = make_db().await; - // Two failures: the first is the never-synced grace attempt. - note_transient_sync_failure(&pool, "github", "dns error") - .await - .unwrap(); + // Drive the streak to one below the threshold, so the next failure is the + // one that escalates. Forced rather than looped so the test does not have to + // restate the threshold's value. + set_transient_streak_for_test("github", TRANSIENT_ESCALATION_ATTEMPTS - 1); note_transient_sync_failure(&pool, "github", "dns error") .await .unwrap(); @@ -370,16 +259,16 @@ async fn a_successful_sync_clears_an_escalated_notice() { ); } -/// The coupling that makes escalation reachable at all: recording an error -/// must NEVER write a fresh `last_synced_at`. +/// Recording an error must NEVER write a fresh `last_synced_at`. /// -/// Every provider gates its whole fetch on that column being recent, and -/// [`note_transient_sync_failure`] keys its escalation clock on the SAME -/// column. So stamping `now` here would do two invisible things at once: -/// suppress the next tick's retry, and reset the escalation clock on every -/// failure so the threshold is never reached. The provider would go silent -/// permanently, which is the exact regression this whole change exists to -/// prevent. `azure_devops` had precisely this bug in its own `stamp_error`. +/// Every provider gates its whole fetch on that column being recent, so stamping +/// `now` while recording a FAILURE would suppress the next attempt's retry - the +/// provider would go quiet permanently, having convinced itself it had just +/// succeeded. `azure_devops` had precisely this bug in its own `stamp_error`. +/// +/// This used to also guard the escalation clock, which keyed on the same column. +/// That clock is gone (escalation counts consecutive attempts now), but the +/// retry-suppression half is untouched and is the reason this still matters. #[tokio::test] async fn recording_an_error_never_looks_like_a_successful_sync() { let pool = make_db().await; @@ -397,15 +286,24 @@ async fn recording_an_error_never_looks_like_a_successful_sync() { "an error must not be recorded as a sync: {last}" ); - // And on a row that already holds a real (old) success, recording a - // failure must leave the clock alone rather than pushing it forward. + // And on a row that already holds a real (old) success, recording a failure + // must leave that timestamp alone rather than pushing it forward. set_last_sync(&pool, "github", "-10 hours").await; + let (before,): (String,) = + sqlx::query_as("SELECT last_synced_at FROM pm_sync_state WHERE provider = 'github'") + .fetch_one(&pool) + .await + .unwrap(); stamp_sync_error(&pool, "github", "boom").await.unwrap(); - assert!( - note_transient_sync_failure(&pool, "github", "unreachable") + let (after,): (String,) = + sqlx::query_as("SELECT last_synced_at FROM pm_sync_state WHERE provider = 'github'") + .fetch_one(&pool) .await - .unwrap(), - "recording an error must not reset the escalation clock" + .unwrap(); + assert_eq!( + before, after, + "recording an error must not move last_synced_at - doing so would make the \ + provider skip its next fetch as though it had just succeeded" ); } @@ -451,12 +349,25 @@ fn status_err(status: u16, body: &str) -> anyhow::Error { #[tokio::test] async fn a_transient_failure_at_the_catch_all_raises_no_notice() { let pool = make_db().await; - set_last_sync(&pool, "azure_devops", "-5 minutes").await; + // A UNIQUE provider key, not a real provider name. The consecutive-failure + // streak lives in a process-global map, and several tests in this file drive + // transient failures through `azure_devops`; sharing the key let one test's + // failures count toward another's streak under cargo's parallel threads. + // Nothing here depends on the name being a real provider - the assertion is + // that no notice is raised at all. + reset_transient_streak("catchall_transient"); + set_last_sync(&pool, "catchall_transient", "-5 minutes").await; - record_sync_failure(&pool, "azure_devops", "wiql", &status_err(503, "down")).await; + record_sync_failure( + &pool, + "catchall_transient", + "wiql", + &status_err(503, "down"), + ) + .await; assert!( - notice(&pool, "pm.azure_devops").await.is_none(), + notice(&pool, "pm.catchall_transient").await.is_none(), "a 503 reaching the catch-all must not raise a credentials banner" ); } @@ -516,14 +427,21 @@ async fn recording_the_same_failure_twice_cannot_degrade_it() { #[tokio::test] async fn re_reporting_a_transient_failure_still_raises_nothing() { let pool = make_db().await; - set_last_sync(&pool, "azure_devops", "-5 minutes").await; + // A UNIQUE provider key, not a real provider name. The consecutive-failure + // streak lives in a process-global map, and several tests in this file drive + // transient failures through `azure_devops`; sharing the key let one test's + // failures count toward another's streak under cargo's parallel threads. + // Nothing here depends on the name being a real provider - the assertion is + // that no notice is raised at all. + reset_transient_streak("rereport_transient"); + set_last_sync(&pool, "rereport_transient", "-5 minutes").await; let err = status_err(503, "down"); - record_sync_failure(&pool, "azure_devops", "wiql", &err).await; - record_sync_failure(&pool, "azure_devops", "refresh", &err).await; + record_sync_failure(&pool, "rereport_transient", "wiql", &err).await; + record_sync_failure(&pool, "rereport_transient", "refresh", &err).await; assert!( - notice(&pool, "pm.azure_devops").await.is_none(), + notice(&pool, "pm.rereport_transient").await.is_none(), "a re-reported blip must stay silent" ); } @@ -597,79 +515,3 @@ async fn every_default_remedy_names_a_place_in_the_app() { ); } } - -/// The grace write must not clobber a sync that SUCCEEDED under it. -/// -/// `note_transient_sync_failure` reads the row's state, then writes the epoch -/// sentinel in a SEPARATE statement. `meridian ticket-update` opens its own -/// pool to this file and runs `run_pm_force_sync` while the daemon's poll loop -/// runs `run_pm_sync`, so a success can land between those two - and the write -/// used to be unconditional, stamping the fresh `last_synced_at` back to the -/// epoch. The provider then read as never-recently-synced and the NEXT failure -/// escalated a user-visible notice for a tracker that was working. -/// -/// Simulating the interleaving directly needs a hook that does not exist, so -/// this drives the same end state the racer would leave: a row that is fresh -/// at the moment of the write. The conditional UPDATE must decline it. -#[tokio::test] -async fn a_concurrent_successful_sync_is_not_stamped_back_to_the_epoch() { - let pool = make_db().await; - // The state the winner of the race leaves behind: a genuinely recent sync. - set_last_sync(&pool, "jira", "-1 minutes").await; - - mark_first_stale_failure(&pool, "jira", "dns error") - .await - .unwrap(); - - let (last,): (String,) = - sqlx::query_as("SELECT last_synced_at FROM pm_sync_state WHERE provider = 'jira'") - .fetch_one(&pool) - .await - .unwrap(); - assert_ne!( - last, "1970-01-01T00:00:00Z", - "a successful sync must not be stamped back to the epoch by the grace write" - ); -} - -/// The grace write still does its job on the case it exists for: a provider -/// that genuinely went stale, with no error recorded. -#[tokio::test] -async fn a_clean_stale_transition_still_gets_its_grace_tick() { - let pool = make_db().await; - set_last_sync(&pool, "jira", "-7 hours").await; - - mark_first_stale_failure(&pool, "jira", "dns error") - .await - .unwrap(); - - let (last,): (String,) = - sqlx::query_as("SELECT last_synced_at FROM pm_sync_state WHERE provider = 'jira'") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(last, "1970-01-01T00:00:00Z"); -} - -/// A row that already carries an error is not a clean stale transition, so the -/// grace write must decline it - the escalation clock is already running. -#[tokio::test] -async fn a_row_with_an_error_does_not_get_a_grace_tick() { - let pool = make_db().await; - set_last_sync(&pool, "jira", "-7 hours").await; - sqlx::query("UPDATE pm_sync_state SET last_error = 'boom' WHERE provider = 'jira'") - .execute(&pool) - .await - .unwrap(); - - mark_first_stale_failure(&pool, "jira", "dns error") - .await - .unwrap(); - - let (last,): (String,) = - sqlx::query_as("SELECT last_synced_at FROM pm_sync_state WHERE provider = 'jira'") - .fetch_one(&pool) - .await - .unwrap(); - assert_ne!(last, "1970-01-01T00:00:00Z"); -} diff --git a/src/pm_worklog/auto_generate/mod.rs b/src/pm_worklog/auto_generate/mod.rs index b8446b205..5bd5724c2 100644 --- a/src/pm_worklog/auto_generate/mod.rs +++ b/src/pm_worklog/auto_generate/mod.rs @@ -98,6 +98,34 @@ mod sweep; use sweep::draft_qualifying_tasks; +/// Whether a sweep may refresh the PM board before matching work to tickets. +/// +/// Exists because "refresh the board first" is correct for one caller and +/// actively unsafe for the other, and the difference is not visible from inside +/// the sweep. +/// +/// Refreshing the board can trigger an OAuth access-token refresh, and Atlassian +/// rotates the refresh token on every use - so a refresh whose response is lost +/// because the machine suspended mid-request destroys the grant permanently (the +/// full mechanism is in `meridian_oauth::refresh_journal`). launchd `KeepAlive` +/// restarts the daemon on every wake, which makes the pipeline's STARTUP pass +/// wake-triggered by construction: exactly the condition where a suspend can land +/// mid-request. Its clock-aligned tick is different - it fires at the hour the +/// user chose for their end-of-day pass, when they are far more likely to be +/// sitting at the machine. +/// +/// So the startup pass drafts against the cached board ([`RefreshBoard::No`]) and +/// the chosen-hour tick refreshes first ([`RefreshBoard::Yes`]). A slightly stale +/// board costs at worst one mis-bound worklog the user can correct; a destroyed +/// grant costs a manual re-authentication and every sync until they notice. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefreshBoard { + /// Refresh `pm_tasks` first. For triggers where a user is plausibly present. + Yes, + /// Draft against whatever is cached. For anything a wake can trigger. + No, +} + /// Fixed qualifying threshold — a day-task needs more than this many tracked /// minutes to be auto-drafted. Not user-configurable: only WHEN Meridian checks /// (`worklog_auto_generate_time`) is a choice; WHICH tasks qualify is not. @@ -120,7 +148,12 @@ fn parse_hh_mm(s: &str) -> Option<(u32, u32)> { /// time) — see the module docs. Tasks are processed one at a time, in order — /// never concurrently. #[tracing::instrument(skip(pool, config))] -pub async fn maybe_auto_generate(pool: &SqlitePool, config: &Config, day_local: &str) { +pub async fn maybe_auto_generate( + pool: &SqlitePool, + config: &Config, + day_local: &str, + refresh: RefreshBoard, +) { let span = tracing::info_span!( "worklog.auto_generate.run", day = day_local, @@ -132,10 +165,10 @@ pub async fn maybe_auto_generate(pool: &SqlitePool, config: &Config, day_local: tasks_drafted = Empty, tasks_failed = Empty, ); - run(pool, config, day_local).instrument(span).await + run(pool, config, day_local, refresh).instrument(span).await } -async fn run(pool: &SqlitePool, config: &Config, day_local: &str) { +async fn run(pool: &SqlitePool, config: &Config, day_local: &str, refresh: RefreshBoard) { let current_span = tracing::Span::current(); let settings = meridian_core::settings::load_runtime_settings(); @@ -159,7 +192,7 @@ async fn run(pool: &SqlitePool, config: &Config, day_local: &str) { } current_span.record("gate", "ran"); - draft_qualifying_tasks(pool, config, day_local, ¤t_span).await; + draft_qualifying_tasks(pool, config, day_local, ¤t_span, refresh).await; } /// The "Generate now" path — the same day-task draft sweep as [`maybe_auto_generate`], @@ -184,7 +217,9 @@ pub async fn generate_now(pool: &SqlitePool, config: &Config, day_local: &str) { async { let current_span = tracing::Span::current(); current_span.record("gate", "ran"); - draft_qualifying_tasks(pool, config, day_local, ¤t_span).await; + // On-demand: the user pressed Generate, so they are unambiguously present + // and this refresh cannot be a wake-triggered one. + draft_qualifying_tasks(pool, config, day_local, ¤t_span, RefreshBoard::Yes).await; } .instrument(span) .await @@ -248,4 +283,43 @@ mod tests { "the tracker gate is back - it disables auto-generate for every solo user" ); } + + /// THE WAKE-TRIGGERED PASS MUST NOT REFRESH THE BOARD. + /// + /// Source-level, because getting this wrong is silent: the startup pass would + /// simply work, refresh the board, and occasionally destroy an Atlassian grant + /// when the machine re-suspended mid-token-exchange. launchd `KeepAlive` + /// restarts the daemon on every wake, so `run_loop`'s pre-loop + /// `end_of_day_pass` IS the wake path - see [`RefreshBoard`]. + /// + /// Pins the pairing rather than the values alone: the startup call must pass + /// `No` and the clock-aligned tick must pass `Yes`. A future edit that + /// "simplifies" them to one shared argument fails here. + #[test] + fn the_startup_pass_never_refreshes_and_the_chosen_hour_tick_does() { + let src = include_str!("../../worklog_pipeline.rs"); + let code: String = src + .lines() + .filter(|l: &&str| !l.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + + let no_calls = code.matches("RefreshBoard::No").count(); + let yes_calls = code.matches("RefreshBoard::Yes").count(); + assert_eq!( + no_calls, 1, + "exactly one end_of_day_pass call - the wake-triggered startup one - \ + may pass RefreshBoard::No; found {no_calls}" + ); + assert_eq!( + yes_calls, 1, + "exactly one end_of_day_pass call - the chosen-hour tick - may pass \ + RefreshBoard::Yes; found {yes_calls}" + ); + // And no call may omit the decision by defaulting it. + assert!( + !code.contains("end_of_day_pass(&pool, &full_cfg)."), + "every end_of_day_pass call must state its RefreshBoard explicitly" + ); + } } diff --git a/src/pm_worklog/auto_generate/sweep.rs b/src/pm_worklog/auto_generate/sweep.rs index f0f66e796..3fe573d0b 100644 --- a/src/pm_worklog/auto_generate/sweep.rs +++ b/src/pm_worklog/auto_generate/sweep.rs @@ -54,6 +54,7 @@ pub(super) async fn draft_qualifying_tasks( config: &Config, day_local: &str, span: &tracing::Span, + refresh: super::RefreshBoard, ) -> SweepCounts { // Refresh the board before matching work to tickets. Nothing does this on a // timer any more (see `main.rs`'s poll loop), and this is the one consumer @@ -65,11 +66,23 @@ pub(super) async fn draft_qualifying_tasks( // separately, so both sweeps get it from one call site and the two can never // disagree about whether a run refreshed first. Gated (a fresh cache is a // no-op) and log-and-continue: a failed sync must not cancel drafting. - if let Err(e) = crate::intelligence::run_pm_sync(pool, config).await { - tracing::warn!( - day = day_local, error = %crate::errors::chain(&e), - "worklog: pre-draft PM sync failed — matching against the cached board" - ); + match refresh { + super::RefreshBoard::Yes => { + if let Err(e) = crate::intelligence::run_pm_sync(pool, config).await { + tracing::warn!( + day = day_local, error = %crate::errors::chain(&e), + "worklog: pre-draft PM sync failed — matching against the cached board" + ); + } + } + // Not merely skipped for speed: see `super::RefreshBoard`. A refresh here + // can trigger a rotating-OAuth-token exchange, and this caller is + // wake-triggered, which is the one condition under which losing that + // exchange's response destroys the user's grant. + super::RefreshBoard::No => tracing::debug!( + day = day_local, + "worklog: drafting against the cached board - this pass can be wake-triggered" + ), } let tasks = match meridian_core::day_tasks::get_day_tasks(pool, day_local).await { Ok(resp) => resp.tasks, @@ -265,8 +278,14 @@ mod tests { put_draft(&pool, "T1").await; put_draft(&pool, "T2").await; - let counts = - draft_qualifying_tasks(&pool, &no_tracker_config(), DAY, &tracing::Span::none()).await; + let counts = draft_qualifying_tasks( + &pool, + &no_tracker_config(), + DAY, + &tracing::Span::none(), + crate::pm_worklog::auto_generate::RefreshBoard::No, + ) + .await; assert_eq!( counts, @@ -289,8 +308,14 @@ mod tests { put_task(&pool, "T1", QUALIFYING_MINUTES - 1).await; put_draft(&pool, "T1").await; - let counts = - draft_qualifying_tasks(&pool, &no_tracker_config(), DAY, &tracing::Span::none()).await; + let counts = draft_qualifying_tasks( + &pool, + &no_tracker_config(), + DAY, + &tracing::Span::none(), + crate::pm_worklog::auto_generate::RefreshBoard::No, + ) + .await; assert_eq!(counts.total, 1); assert_eq!(counts.qualifying, 0, "under the threshold, never drafted"); @@ -301,8 +326,14 @@ mod tests { #[tokio::test] async fn an_empty_day_sweeps_to_zero() { let pool = seeded().await; - let counts = - draft_qualifying_tasks(&pool, &no_tracker_config(), DAY, &tracing::Span::none()).await; + let counts = draft_qualifying_tasks( + &pool, + &no_tracker_config(), + DAY, + &tracing::Span::none(), + crate::pm_worklog::auto_generate::RefreshBoard::No, + ) + .await; assert_eq!(counts, SweepCounts::default()); } } diff --git a/src/worklog_pipeline.rs b/src/worklog_pipeline.rs index ed6fc3196..a17ebd5a5 100644 --- a/src/worklog_pipeline.rs +++ b/src/worklog_pipeline.rs @@ -30,6 +30,7 @@ use sqlx::SqlitePool; use tokio::sync::watch; use tracing::Instrument; +use crate::pm_worklog::auto_generate::RefreshBoard; use crate::pm_worklog::{ledger, PmWorklogConfig}; // `pub(crate)` where the LLM-Lab replay ([`crate::llm_experiment`]) reuses the request @@ -516,7 +517,14 @@ pub async fn run_loop(pool: SqlitePool, db_path: String, mut shutdown_rx: watch: tracing::info!("worklog-pipeline driver stopped"); return; } - end_of_day_pass(&pool, &full_cfg).await; + // `RefreshBoard::No` on the STARTUP pass, and this is a data-safety choice + // rather than an optimisation. launchd `KeepAlive` restarts the daemon on + // every wake, so anything the startup pass does is wake-triggered - and a + // board refresh can trigger an OAuth token refresh, which is the operation + // that permanently destroys an Atlassian grant if the machine re-suspends + // mid-request (see `meridian_oauth::refresh_journal`). Drafting against the + // board the user's last dashboard visit left behind is the safe trade. + end_of_day_pass(&pool, &full_cfg, RefreshBoard::No).await; loop { let dur = next_worklog_wake(); @@ -526,7 +534,12 @@ pub async fn run_loop(pool: SqlitePool, db_path: String, mut shutdown_rx: watch: if catch_up_pass(&pool, &cfg, &db_path, &mut shutdown_rx).await { break; } - end_of_day_pass(&pool, &full_cfg).await; + // The clock-aligned tick MAY refresh: it fires at the hour the + // user themselves chose for their end-of-day pass, so they are + // far more likely to be at the machine, and a fresh board is what + // stops an hour of work being bound to a ticket that closed + // earlier today. + end_of_day_pass(&pool, &full_cfg, RefreshBoard::Yes).await; } } } @@ -542,9 +555,14 @@ pub async fn run_loop(pool: SqlitePool, db_path: String, mut shutdown_rx: watch: /// summary first would score a plan whose matches had not landed yet. Both gate /// themselves on `worklog_auto_generate_time`, so on every hour but the chosen one /// (through midnight, self-healing) this is a pair of cheap no-ops. -async fn end_of_day_pass(pool: &SqlitePool, full_cfg: &crate::config::Config) { +async fn end_of_day_pass( + pool: &SqlitePool, + full_cfg: &crate::config::Config, + refresh: RefreshBoard, +) { let day_local = Local::now().date_naive().format("%Y-%m-%d").to_string(); - crate::pm_worklog::auto_generate::maybe_auto_generate(pool, full_cfg, &day_local).await; + crate::pm_worklog::auto_generate::maybe_auto_generate(pool, full_cfg, &day_local, refresh) + .await; crate::day_summary::auto::maybe_auto_summarise(pool, &day_local).await; } From f8ef28cdfe566375d524d0d37077f7320f37266c Mon Sep 17 00:00:00 2001 From: adityaharishch Date: Mon, 31 Aug 2026 18:48:38 +0530 Subject: [PATCH 28/53] fix(daemon): bound background-task shutdown and stop dev tray false-installing over itself The coding-agent summariser's drain() ran up to batch_per_tick (32) rows sequentially with no shutdown check between them, each shelling out to the agent's own CLI for up to ~2 minutes - so a daemon shutdown mid-batch could leave meridian.db open far past the tray installer's 10s wait_for_db_unheld budget, risking the double-writer corruption this repo has hit before. It now checks shutdown between rows, and main.rs's shutdown sequence joins all background-task handles (concurrently, bounded to one shutdown budget) before checkpointing/closing the pool, instead of firing the signal and moving on with no idea whether they'd stopped. Separately, bundled_backend_dir() decided "packaged install" purely from whether a backend/ directory existed under resource_dir() - true even for a dev/debug tray if a stray directory is ever left there (e.g. from an earlier debug-profile build), which made a dev tray try to "install" a mismatched binary over itself, bootout a launchd agent that was never loaded, then time out waiting for its own sibling dev daemon to release meridian.db. Gated on !debug_assertions to match the same convention the encryption-migration path already uses. --- .../summariser/mod.rs | 103 ++++++++++++++++- src/main.rs | 104 +++++++++++++++--- tray/src-tauri/src/backend_install.rs | 24 ++++ 3 files changed, 215 insertions(+), 16 deletions(-) diff --git a/src/coding_agent_session_ingest/summariser/mod.rs b/src/coding_agent_session_ingest/summariser/mod.rs index bc22f90af..acbff2e31 100644 --- a/src/coding_agent_session_ingest/summariser/mod.rs +++ b/src/coding_agent_session_ingest/summariser/mod.rs @@ -595,7 +595,14 @@ pub async fn run_loop( // Only rows whose source is in backoff are skipped; other sources continue. let mut source_backoff: HashMap = HashMap::new(); loop { - let all_backed_off = drain(&pool, &cfg, &mut attempts, &mut source_backoff).await; + let all_backed_off = drain( + &pool, + &cfg, + &mut attempts, + &mut source_backoff, + &shutdown_rx, + ) + .await; let wait = if all_backed_off { // Nothing could be processed (all pending rows are from backed-off // sources). Wait out the backoff period rather than spinning. @@ -643,6 +650,7 @@ async fn drain( cfg: &SummariserConfig, attempts: &mut HashMap, source_backoff: &mut HashMap, + shutdown_rx: &watch::Receiver, ) -> bool { // Expire stale backoffs before deciding what to skip. let now_instant = std::time::Instant::now(); @@ -671,6 +679,24 @@ async fn drain( let mut skipped_backoff = 0u32; for row in &rows { + // Each row shells out to the agent's own CLI (`claude -p` / `codex exec` + // / …), which can run for tens of seconds to a couple of minutes — and + // this loop runs up to `batch_per_tick` (default 32) of them + // sequentially. Without this check, a daemon shutdown mid-batch would + // not be observed until the whole batch drained (worst case, tens of + // minutes), holding `meridian.db` open long past the tray installer's + // 10s `wait_for_db_unheld` budget (`backend_install.rs`) and risking + // exactly the double-writer corruption that guard exists to prevent. + // Checked between rows, not mid-subprocess-call: the subprocess isn't + // cancellation-safe, so bounding exposure to "at most one in-flight + // row" is the safe stopping point, not "any point at all". + if *shutdown_rx.borrow() { + tracing::info!( + remaining = rows.len() - summarised as usize - skipped_backoff as usize, + "coding-agent summariser: shutdown requested mid-drain — stopping early" + ); + return false; + } // Skip rows whose agent source is still in rate-limit backoff. if source_backoff.contains_key(&row.agent) { skipped_backoff += 1; @@ -946,4 +972,79 @@ mod tests { "row 2's independent failure count must be untouched" ); } + + /// THE regression this exists for: `drain()` used to process its whole + /// batch (up to `batch_per_tick`, default 32, each shelling out to the + /// agent's own CLI) with no shutdown check at all, so a daemon SIGTERM + /// mid-batch went unnoticed until every row finished — worst case, tens + /// of minutes with `meridian.db` still open. That blew straight through + /// the tray installer's 10s `wait_for_db_unheld` budget + /// (`backend_install.rs`), which is the guard standing between an update + /// and the exact double-writer corruption documented in this repo's + /// history. + /// + /// Pre-signals shutdown BEFORE calling `drain()` (rather than racing a + /// real subprocess mid-row, which isn't cancellation-safe) and asserts + /// the pending row is untouched — proving the loop bailed before ever + /// calling `summarise_one`, not merely that it returned some value. + #[tokio::test] + async fn drain_stops_before_touching_any_row_once_shutdown_is_signalled() { + use crate::coding_agent_session_ingest::db as cdb; + use crate::coding_agent_session_ingest::segment::Segment; + use sqlx::sqlite::SqliteConnectOptions; + use std::str::FromStr; + + let opts = SqliteConnectOptions::from_str("sqlite::memory:") + .unwrap() + .create_if_missing(true); + let pool = SqlitePool::connect_with(opts).await.unwrap(); + sqlx::migrate!("src/migrations").run(&pool).await.unwrap(); + db::ensure_summary_source_column(&pool).await.unwrap(); + + let seg = Segment { + session_uuid: "u1".into(), + agent: "claude_code".into(), + cwd: Some("/repo".into()), + segment_started_at: "2026-05-20T08:00:00.000000+00:00".into(), + started_at: "2026-05-20T08:00:00.000000+00:00".into(), + ended_at: "2026-05-20T08:30:00.000000+00:00".into(), + user_turns: 2, + assistant_turns: 2, + active_seconds: 300, + transcript: "x".repeat(900), + is_last: false, + title: None, + }; + let id = cdb::upsert_segment(&pool, &seg, true, Some("2026-05-20T09:00:00.000000+00:00")) + .await + .unwrap() + .unwrap(); + + let cfg = SummariserConfig::from_env(); + let mut attempts = HashMap::new(); + let mut source_backoff = HashMap::new(); + // Already true when drain() starts — the same state the loop sees + // when a SIGTERM lands between two drain() calls, or (with this fix) + // between two rows of the same batch. + let (_tx, rx) = watch::channel(true); + + let all_backed_off = drain(&pool, &cfg, &mut attempts, &mut source_backoff, &rx).await; + assert!( + !all_backed_off, + "a shutdown bail-out is not a backoff condition" + ); + + let (method,): (String,) = + sqlx::query_as("SELECT task_method FROM app_sessions WHERE id = ?") + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + method, + db::TASK_METHOD_PENDING, + "the row must be untouched — drain() must bail before calling \ + summarise_one, not merely stop looping afterward" + ); + } } diff --git a/src/main.rs b/src/main.rs index c01754789..bdfc7c88b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1460,6 +1460,12 @@ async fn main() -> Result<()> { } let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + // Every task below holds its own clone of the `meridian` pool and must be + // known to have actually stopped touching it before shutdown checkpoints + // and closes that pool — see the bounded join at the "9. Shutdown" step, + // and its doc comment for why a fire-and-forget `tokio::spawn` here isn't + // enough on its own. + let mut background_tasks: Vec<(&'static str, tokio::task::JoinHandle<()>)> = Vec::new(); // 8a-bis. Coding-agent tasks (both gated — dormant if neither agent is // present). The indexer turns Claude Code / Codex JSONLs into @@ -1472,18 +1478,26 @@ async fn main() -> Result<()> { let pool_idx = meridian.clone(); let notify_idx = ca_notify.clone(); let rx_idx = shutdown_rx.clone(); - tokio::spawn(async move { - meridian::coding_agent_session_ingest::indexer::run_loop(pool_idx, notify_idx, rx_idx) + background_tasks.push(( + "coding_agent_indexer", + tokio::spawn(async move { + meridian::coding_agent_session_ingest::indexer::run_loop( + pool_idx, notify_idx, rx_idx, + ) .await; - }); + }), + )); let pool_sum = meridian.clone(); let rx_sum = shutdown_rx.clone(); - tokio::spawn(async move { - meridian::coding_agent_session_ingest::summariser::run_loop( - pool_sum, ca_notify, rx_sum, - ) - .await; - }); + background_tasks.push(( + "coding_agent_summariser", + tokio::spawn(async move { + meridian::coding_agent_session_ingest::summariser::run_loop( + pool_sum, ca_notify, rx_sum, + ) + .await; + }), + )); } // 7d. PM-worklog driver: hour-level pipeline — clock-aligned (HH:03 local), runs @@ -1493,9 +1507,12 @@ async fn main() -> Result<()> { let pool_pm = meridian.clone(); let db_path_pm = initial_cfg.meridian_db.clone(); let rx_pm = shutdown_rx.clone(); - tokio::spawn(async move { - meridian::worklog_pipeline::run_loop(pool_pm, db_path_pm, rx_pm).await; - }); + background_tasks.push(( + "worklog_pipeline", + tokio::spawn(async move { + meridian::worklog_pipeline::run_loop(pool_pm, db_path_pm, rx_pm).await; + }), + )); } // 7e. PM-worklog approved-poster: the ~60s sweep that posts worklogs the user @@ -1505,9 +1522,12 @@ async fn main() -> Result<()> { { let pool_post = meridian.clone(); let rx_post = shutdown_rx.clone(); - tokio::spawn(async move { - meridian::pm_worklog::run_post_loop(pool_post, rx_post).await; - }); + background_tasks.push(( + "pm_worklog_post", + tokio::spawn(async move { + meridian::pm_worklog::run_post_loop(pool_post, rx_post).await; + }), + )); } // 7f. Telemetry spool shipper: drains ~/.meridian/telemetry/pending/ to @@ -1717,6 +1737,60 @@ async fn main() -> Result<()> { // 9. Shutdown tracing::info!(pid = std::process::id() as i64, "shutting down"); + + // Wait for every background task to actually stop touching `meridian` + // before checkpointing/closing it, instead of just firing the shutdown + // signal and hoping. Each task above holds its own clone of this same + // pool, and closing it while one is still mid-query races the exact + // "held open after stopping the daemon" failure `wait_for_db_unheld` + // (`backend_install.rs`) exists to catch during an app update: on macOS + // the rename underneath a live writer SUCCEEDS, so two writers end up + // sharing one WAL — the root cause of the `code: 11` corruption in this + // repo's history. Bounded rather than unconditional: a task that is + // stuck on an external subprocess (the coding-agent summariser's `claude + // -p` / `codex exec`) must not hang the daemon's own shutdown forever. + // The bound is loud on expiry so a future recurrence shows up in + // `meridian logs` instead of silently racing the checkpoint below — see + // the module doc on `poll::permissions` for why a silent failure mode + // here previously cost days to diagnose. + const BACKGROUND_TASK_SHUTDOWN_BUDGET: std::time::Duration = std::time::Duration::from_secs(5); + // Joined CONCURRENTLY, not one after another: these tasks are already + // running in parallel, so waiting on them sequentially would let 4 tasks + // each burn their own 5s budget and add up to 20s to every shutdown - on + // a machine slow enough to need the full budget even once, that's also + // long enough to blow past `bootout_agent_and_wait`'s own 15s wait for + // the launchd label to clear (`backend_install.rs`), turning a slow but + // healthy shutdown into a failed update. `join_all` bounds the total added + // latency to one budget's worth, matching how long the slowest task + // actually needed. + let results = futures::future::join_all(background_tasks.into_iter().map( + |(name, handle)| async move { + ( + name, + tokio::time::timeout(BACKGROUND_TASK_SHUTDOWN_BUDGET, handle).await, + ) + }, + )) + .await; + for (name, outcome) in results { + match outcome { + Ok(Ok(())) => tracing::debug!(task = name, "background task stopped for shutdown"), + Ok(Err(e)) => tracing::warn!( + task = name, + error = %e, // not-anyhow: tokio::task::JoinError, no chain to walk + "background task panicked during shutdown" + ), + Err(_) => tracing::error!( + task = name, + budget_s = BACKGROUND_TASK_SHUTDOWN_BUDGET.as_secs(), + "background task did not stop within the shutdown budget — \ + it may still be holding the database open (e.g. mid an \ + external subprocess call); proceeding with checkpoint/close \ + anyway rather than hanging shutdown indefinitely" + ), + } + } + meridian::platform::release_endpoint(); // See `db::meridian::checkpoint_wal`'s doc for why this runs before every // close, not just a plain shutdown. Best-effort: a failed checkpoint must diff --git a/tray/src-tauri/src/backend_install.rs b/tray/src-tauri/src/backend_install.rs index 5cdf9fd7b..2c12fc046 100644 --- a/tray/src-tauri/src/backend_install.rs +++ b/tray/src-tauri/src/backend_install.rs @@ -485,7 +485,31 @@ pub(crate) async fn ensure_daemon_running(home: &Path) { } /// `Meridian.app/Contents/Resources/backend/` when it exists, else `None`. +/// +/// Gated on `!cfg!(debug_assertions)` as well as the directory check — a +/// `tauri dev` / `cargo run` build's `resource_dir()` resolves under +/// `target/debug/`, and if ANYTHING ever leaves a `backend/` subdirectory +/// there (a stray artifact from an earlier debug-profile `tauri build`, a +/// manual test, whatever), `dir.is_dir()` alone can't tell that apart from a +/// real packaged install. That false positive is not cosmetic: +/// [`ensure_backend_installed`] would then call [`install`], which on macOS +/// runs [`stop_daemon_for_migration`] against `meridian.db` — booting out a +/// launchd agent that was never registered for this run (a no-op) and then +/// polling `lsof` for up to 10s, which always times out because the actual +/// holder is the sibling `cargo run` daemon `bootout` can't touch, not the +/// launchd job it targets. That raises `tray.backend_install_failed` +/// ("meridian.db is still held open 10s after stopping the daemon") on every +/// dev-tray launch, with a timestamp that tracks each restart — measured +/// verbatim on 2026-08-31, traced to exactly one stray directory left over +/// from an Aug 16 debug build. `debug_assertions` is a compile-time constant +/// for the running binary's own profile, so this can never be fooled by +/// what's left lying around on disk the way the directory check alone was. +/// Matches the same convention already used for the encryption-migration +/// gate in `lib.rs`'s setup hook. fn bundled_backend_dir(app: &tauri::AppHandle) -> Option { + if cfg!(debug_assertions) { + return None; + } let dir = app.path().resource_dir().ok()?.join("backend"); dir.is_dir().then_some(dir) } From 0dc9bf4346ed0dd28cbc7148067949ca0794eb4d Mon Sep 17 00:00:00 2001 From: adityaharishch Date: Tue, 1 Sep 2026 08:38:45 +0530 Subject: [PATCH 29/53] fix(cli): reject unrecognised flags instead of running the command bare Every subcommand parses argv by searching for tokens it knows, so an unknown flag contributed nothing and left the command running its DEFAULTS. `meridian coding-agent-summarise --help` therefore summarised and persisted real rows (reported from production 2026-08-27), and the same hole sat in front of `worklog-post-approved`, which takes no arguments and posts every approved worklog as a real comment to Jira, Linear and GitHub - so `--help` on it would have written irreversibly to a customer's tracker. `tasks-sync` and `pm-sync` were the same shape one tier down. The other subcommands were safe only because they happen to require a positional argument. Adds a single table of accepted flags per subcommand, enforced once before the dispatch chain so no side effect is reachable through a typo, and answers --help everywhere instead of dispatching it. Only '-'-prefixed tokens are inspected against a flat allowlist; flag values are deliberately not modelled, since wrongly rejecting a working invocation would be worse than the bug. A source-scan test fails the build when a subcommand is dispatched in main.rs (or plan_tasks::cli) with no entry in the table, so a new command cannot silently opt out of validation the way this hole opened. --- src/cli_flags.rs | 564 +++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 13 ++ 3 files changed, 578 insertions(+) create mode 100644 src/cli_flags.rs diff --git a/src/cli_flags.rs b/src/cli_flags.rs new file mode 100644 index 000000000..1bdf43b8e --- /dev/null +++ b/src/cli_flags.rs @@ -0,0 +1,564 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +//! Strict flag validation for the hand-rolled subcommand CLI in `main.rs`. +//! +//! # The bug this exists for +//! +//! Every subcommand parses its arguments by searching argv for tokens it +//! recognises — `args.iter().any(|a| a == "--dry-run")`, `flag("--day")`. +//! Nothing ever walked argv asking whether a token *was* recognised, so an +//! unknown flag contributed nothing and was silently discarded. What ran was +//! then the command's **default configuration**, byte-identical to invoking it +//! bare. +//! +//! Reported from production on 2026-08-27: an operator investigating a stuck +//! queue ran `meridian coding-agent-summarise --help` expecting usage text and +//! instead summarised and persisted real rows (`dry_run` defaults to `false`, +//! and `summariser::cli_summarise` passes `!dry_run` as its persist flag). +//! +//! That was the mild case. The same hole sat in front of +//! **`worklog-post-approved`**, which takes no arguments at all and posts every +//! approved worklog as a real comment to Jira / Linear / GitHub +//! (`pm_worklog::cli_post_approved` — "the only path that writes to real +//! Jira"). `meridian worklog-post-approved --help` would have written to a +//! customer's tracker, irreversibly and visibly to their whole team, in +//! response to the single most natural thing an operator does when probing an +//! unfamiliar command. +//! +//! `tasks-sync` and `pm-sync` were the same shape one tier down (DB mirror +//! rewrites including terminal-row pruning). The remaining subcommands were +//! safe only *by accident* — they happen to require a positional argument and +//! bail without it. That is a coincidence, not a guard, and it would not have +//! survived the next all-optional-flag subcommand. +//! +//! # Why a table rather than a check per call site +//! +//! `main.rs` dispatches through ~30 sequential `if argv[1] == "..."` blocks. +//! Adding a validation call inside each one is a rule that has to be +//! remembered 30 times and then again for every new subcommand — the same +//! shape of omission that caused this. Instead [`SPECS`] is a single table +//! consulted **once**, before the dispatch chain runs, and +//! [`tests::every_dispatched_subcommand_is_registered`] scans `main.rs` for +//! dispatch literals and fails the build if one has no entry here. Forgetting +//! to register a new subcommand is a compile-time-ish error, not a latent +//! production write. +//! +//! # Scope of the check, deliberately narrow +//! +//! Only `-`-prefixed tokens are inspected, against a flat allowlist per +//! subcommand. Flag *values* are not modelled: a value that does not itself +//! start with `-` is simply not a candidate, and none of the flags below takes +//! a negative number. Modelling value-consumption would buy nothing and would +//! introduce a way to *wrongly reject* a working invocation — the one failure +//! mode worse than the bug being fixed, since these commands are load-bearing +//! for the tray. +//! +//! # Who calls this +//! [`enforce`] runs from `main.rs` immediately before the subcommand dispatch +//! chain, ahead of every side effect. +//! +//! # Related +//! - `main.rs`'s unknown-*subcommand* rejection, which this mirrors for flags. +//! That check deliberately skips anything starting with `-` (bare `meridian` +//! and `meridian --version` still start the daemon), and that exclusion is +//! precisely the gap this module closes. + +/// One subcommand's accepted flags plus the usage line `--help` prints. +pub struct Spec { + /// The argv[1] literal `main.rs` dispatches on. + pub name: &'static str, + /// Every `-`-prefixed token this subcommand accepts. `--help` / `-h` are + /// accepted everywhere and must not be listed. + pub flags: &'static [&'static str], + /// Shown by `--help`. One line, plain hyphens only (user-facing text). + pub usage: &'static str, +} + +/// Every subcommand `main.rs` dispatches, with the flags it actually reads. +/// +/// Kept in dispatch order so this table can be diffed against `main.rs` by eye. +/// A subcommand with no flags gets an empty slice — that is the strongest +/// entry in the table, not a placeholder: it is what makes +/// `worklog-post-approved --anything` an error instead of a tracker write. +pub const SPECS: &[Spec] = &[ + Spec { + name: "coding-agent-hook", + flags: &[], + usage: "meridian coding-agent-hook (reads a SessionEnd JSON payload on stdin)", + }, + Spec { + name: "coding-agent-summarise", + flags: &["--dry-run", "--day", "--limit"], + usage: "meridian coding-agent-summarise [--dry-run] [--day YYYY-MM-DD] [--limit N]", + }, + Spec { + name: "db", + flags: &[], + usage: "meridian db ", + }, + Spec { + name: "oauth-login", + flags: &["--app-key", "--client-id", "--port"], + usage: "meridian oauth-login [--client-id ID] [--app-key KEY] [--port N]", + }, + Spec { + name: "worklog-hour", + flags: &[], + usage: "meridian worklog-hour ", + }, + Spec { + name: "llm-experiment", + flags: &[ + "--day", + "--hour", + "--id", + "--limit", + "--process", + "--task-id", + "--task-json", + "--variant", + "--variants", + ], + usage: "meridian llm-experiment [flags]", + }, + Spec { + name: "worklog-post-approved", + flags: &[], + usage: "meridian worklog-post-approved (posts approved worklogs to your tracker)", + }, + Spec { + name: "db-export-plaintext", + flags: &["--out"], + usage: "meridian db-export-plaintext [--out PATH]", + }, + Spec { + name: "tasks-sync", + flags: &[], + usage: "meridian tasks-sync (forces a fetch from every configured tracker)", + }, + Spec { + name: "pm-sync", + flags: &[], + usage: "meridian pm-sync (refreshes the tracker mirror if it is stale)", + }, + Spec { + name: "ticket-update", + flags: &["--field", "--key", "--provider", "--value"], + usage: "meridian ticket-update --key KEY --provider P --field F --value V", + }, + Spec { + name: "ticket-parents", + flags: &["--key", "--provider"], + usage: "meridian ticket-parents --key KEY --provider P", + }, + Spec { + name: "ticket-statuses", + flags: &["--key", "--provider"], + usage: "meridian ticket-statuses --key KEY --provider P", + }, + Spec { + name: "ticket-set-status", + flags: &["--key", "--provider", "--status"], + usage: "meridian ticket-set-status --key KEY --provider P --status S", + }, + Spec { + name: "plan-task-draft", + flags: &["--note"], + usage: "meridian plan-task-draft --note \"\"", + }, + Spec { + name: "plan-task-create", + flags: &["--title", "--description", "--issue-type"], + usage: "meridian plan-task-create --title T [--description D] [--issue-type Task|Bug]", + }, + Spec { + name: "plan-task-edit", + flags: &["--key", "--title", "--description"], + usage: "meridian plan-task-edit --key K [--title T] [--description D]", + }, + Spec { + name: "plan-task-done", + flags: &["--key", "--done"], + usage: "meridian plan-task-done --key K --done true|false", + }, + Spec { + name: "plan-task-delete", + flags: &["--key"], + usage: "meridian plan-task-delete --key K", + }, + Spec { + name: "day-summary", + flags: &["--day", "--now"], + usage: "meridian day-summary [--day YYYY-MM-DD] [--now]", + }, + Spec { + name: "day-summary-get", + flags: &["--day"], + usage: "meridian day-summary-get [--day YYYY-MM-DD]", + }, + Spec { + name: "day-summary-data", + flags: &["--day"], + usage: "meridian day-summary-data [--day YYYY-MM-DD]", + }, + Spec { + name: "worklog-generate", + flags: &["--day", "--task-id"], + usage: "meridian worklog-generate --task-id ID [--day YYYY-MM-DD]", + }, + Spec { + name: "worklog-generate-get", + flags: &["--day", "--task-id"], + usage: "meridian worklog-generate-get --task-id ID [--day YYYY-MM-DD]", + }, + Spec { + name: "worklog-generate-approve", + flags: &["--day", "--task-id"], + usage: "meridian worklog-generate-approve --task-id ID [--day YYYY-MM-DD]", + }, + Spec { + name: "worklog-escalate-create", + flags: &["--task"], + usage: "meridian worklog-escalate-create --task ID", + }, + Spec { + name: "worklog-escalate-match", + flags: &["--target", "--task"], + usage: "meridian worklog-escalate-match --task ID --target KEY", + }, + Spec { + name: "worklog-status", + flags: &["--day"], + usage: "meridian worklog-status [--day YYYY-MM-DD]", + }, + Spec { + name: "doctor", + flags: &["--dry-run", "--fix", "--porcelain"], + usage: "meridian doctor [--fix] [--dry-run] [--porcelain]", + }, + Spec { + name: "uninstall", + flags: &[ + "--dry-run", + "--json", + "--purge", + "--remove-data", + "--remove-models", + "--remove-runtime", + "--yes", + ], + usage: "meridian uninstall [--dry-run] [--purge] [--remove-data] \ + [--remove-models] [--remove-runtime] [--yes] [--json]", + }, + Spec { + name: "telemetry", + flags: &["--auth", "--endpoint", "--out", "--since"], + usage: "meridian telemetry [--out PATH] [--since MICROS] \ + [--endpoint URL] [--auth BASE64]", + }, + Spec { + name: "logs", + flags: &["--min-severity", "--service", "-n", "-f"], + usage: "meridian logs [--service NAME] [--min-severity LEVEL] [-n N] [-f]", + }, +]; + +/// What [`check`] decided about an argv. +#[derive(Debug, PartialEq, Eq)] +pub enum Outcome { + /// Not a registered subcommand (bare daemon start, `--version`, or a typo + /// the unknown-subcommand check in `main.rs` will catch). No opinion. + NotOurs, + /// Every flag is recognised — carry on and dispatch. + Ok, + /// `--help` / `-h` was asked for; print this and exit 0. + Help(&'static str), + /// An unrecognised flag. Carries the offending token and the usage line. + Unknown { flag: String, usage: &'static str }, +} + +/// The [`Spec`] for `name`, if it is a registered subcommand. +pub fn spec(name: &str) -> Option<&'static Spec> { + SPECS.iter().find(|s| s.name == name) +} + +/// Decide what to do with `argv` (the full argv, including argv[0]). +/// +/// Pure so it can be tested without a process: [`enforce`] is the thin wrapper +/// that prints and exits. +/// +/// `--help` wins over an unknown flag deliberately. Someone typing +/// `meridian worklog-post-approved --help --bogus` is asking what the command +/// does, and answering that is strictly safer than an error that might send +/// them off to retry it bare — which is the invocation that actually posts. +pub fn check(argv: &[String]) -> Outcome { + let Some(sub) = argv.get(1) else { + return Outcome::NotOurs; + }; + let Some(spec) = spec(sub) else { + return Outcome::NotOurs; + }; + + let mut unknown: Option = None; + for raw in argv.iter().skip(2) { + // `--flag=value` is a shape people type even where the parser wants a + // separate token; validate the name half so it reports the flag rather + // than the whole pair. + let token = raw.split('=').next().unwrap_or(raw.as_str()); + if !token.starts_with('-') { + continue; // positional, or a flag's value + } + if token == "--help" || token == "-h" { + return Outcome::Help(spec.usage); + } + if !spec.flags.contains(&token) && unknown.is_none() { + unknown = Some(token.to_string()); + } + } + + match unknown { + Some(flag) => Outcome::Unknown { + flag, + usage: spec.usage, + }, + None => Outcome::Ok, + } +} + +/// Apply [`check`] to the real argv, printing and exiting where it says to. +/// +/// Exit 2 for a bad flag matches the code `main.rs` already uses for an unknown +/// subcommand, so callers see one consistent bad-usage code. +pub fn enforce() { + let argv: Vec = std::env::args().collect(); + match check(&argv) { + Outcome::NotOurs | Outcome::Ok => {} + Outcome::Help(usage) => { + println!("{usage}"); + std::process::exit(0); + } + Outcome::Unknown { flag, usage } => { + eprintln!( + "meridian: unknown flag {flag:?}\nusage: {usage}\n\n\ + Refusing to run rather than ignoring it - an unrecognised flag \ + used to be silently dropped, which ran the command with its \ + defaults instead." + ); + std::process::exit(2); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn argv(rest: &[&str]) -> Vec { + std::iter::once("meridian".to_string()) + .chain(rest.iter().map(|s| s.to_string())) + .collect() + } + + /// THE incident, at its worst reachable point. `worklog-post-approved` + /// takes no arguments and posts real comments to a customer's tracker; + /// before this module `--help` ran it. + #[test] + fn help_never_reaches_a_command_that_writes_to_a_real_tracker() { + assert_eq!( + check(&argv(&["worklog-post-approved", "--help"])), + Outcome::Help( + "meridian worklog-post-approved (posts approved worklogs to your tracker)" + ), + "--help must be answered, never dispatched - this command posts to Jira" + ); + assert!( + matches!( + check(&argv(&["worklog-post-approved", "--dry-run"])), + Outcome::Unknown { .. } + ), + "an unknown flag must be refused, not dropped so the command runs bare" + ); + } + + /// The reported incident itself. + #[test] + fn the_reported_summarise_incident_is_refused() { + assert!(matches!( + check(&argv(&["coding-agent-summarise", "--help"])), + Outcome::Help(_) + )); + } + + /// The other direction, and the one that would be a worse regression than + /// the bug: every flag these commands genuinely read must still be accepted. + /// The tray shells out to several of these. + #[test] + fn real_invocations_still_pass() { + for a in [ + vec!["coding-agent-summarise", "--dry-run", "--day", "2026-08-27"], + vec!["coding-agent-summarise", "--limit", "8"], + vec![ + "ticket-update", + "--key", + "KAN-1", + "--provider", + "jira", + "--field", + "f", + "--value", + "v", + ], + vec!["day-summary", "--day", "2026-08-27", "--now"], + vec!["doctor", "--fix", "--dry-run", "--porcelain"], + vec!["logs", "--service", "meridian-rust", "-n", "50", "-f"], + vec!["uninstall", "--purge", "--yes", "--json"], + vec!["db", "repair"], + vec![ + "oauth-login", + "jira", + "--client-id", + "abc", + "--port", + "1234", + ], + vec![ + "telemetry", + "import", + "bundle.tar.gz", + "--endpoint", + "http://x", + "--auth", + "eyJ", + ], + vec!["plan-task-done", "--key", "K", "--done", "true"], + vec!["worklog-post-approved"], + vec!["tasks-sync"], + ] { + assert_eq!( + check(&argv(&a)), + Outcome::Ok, + "a working invocation was rejected: {a:?}" + ); + } + } + + /// A flag's value is not a flag, and `--flag=value` reports the name half. + #[test] + fn values_are_not_mistaken_for_flags() { + assert_eq!( + check(&argv(&["day-summary", "--day", "2026-08-27"])), + Outcome::Ok + ); + assert_eq!( + check(&argv(&["day-summary", "--day=2026-08-27"])), + Outcome::Ok + ); + match check(&argv(&["day-summary", "--nope=1"])) { + Outcome::Unknown { flag, .. } => assert_eq!(flag, "--nope"), + other => panic!("expected the flag name alone, got {other:?}"), + } + } + + /// Bare `meridian` and `meridian --version` start the daemon; that is the + /// documented entry point and this module must not interfere with it. + #[test] + fn the_daemon_entry_point_is_untouched() { + assert_eq!(check(&argv(&[])), Outcome::NotOurs); + assert_eq!(check(&argv(&["--version"])), Outcome::NotOurs); + // A typo is left to main.rs's unknown-subcommand path, which has a + // better message for it (it names the stale-binary case). + assert_eq!(check(&argv(&["restrt"])), Outcome::NotOurs); + } + + /// THE guard that keeps this fix from decaying. A new subcommand added to + /// `main.rs` without an entry here would silently return `NotOurs` and get + /// no flag checking at all - the original hole, reopened one command at a + /// time. Source-scanned because the dispatch chain is `if` statements, not + /// data, so there is nothing else to enumerate it from. + #[test] + fn every_dispatched_subcommand_is_registered() { + let main_rs = include_str!("main.rs"); + let mut missing = Vec::new(); + for line in main_rs.lines() { + // Matches `nth(1).as_deref() == Some("name")`. + let Some(rest) = line.split("nth(1).as_deref() == Some(\"").nth(1) else { + continue; + }; + let Some(name) = rest.split('"').next() else { + continue; + }; + if spec(name).is_none() { + missing.push(name.to_string()); + } + } + assert!( + missing.is_empty(), + "subcommand(s) dispatched in main.rs with no cli_flags::SPECS entry: {missing:?}\n\ + Add one. Without it the subcommand accepts any flag silently, which is how \ + `worklog-post-approved --help` came to post to a real tracker." + ); + } + + /// The `plan-task-*` family dispatches from `plan_tasks::cli`, not through + /// `main.rs`'s chain, so the scan above cannot see it. Pin it separately + /// rather than leaving the family unguarded. + #[test] + fn the_plan_task_family_is_registered() { + let cli_rs = include_str!("plan_tasks/cli.rs"); + let mut missing = Vec::new(); + for line in cli_rs.lines() { + let trimmed = line.trim(); + let Some(rest) = trimmed.strip_prefix("\"plan-task-") else { + continue; + }; + let Some(tail) = rest.split('"').next() else { + continue; + }; + let name = format!("plan-task-{tail}"); + if spec(&name).is_none() { + missing.push(name); + } + } + assert!( + missing.is_empty(), + "plan-task subcommand(s) with no SPECS entry: {missing:?}" + ); + } + + /// Usage strings are user-facing app text: plain hyphens only, per the + /// repo's hard rule on dashes in displayed strings. + #[test] + fn usage_lines_use_plain_hyphens() { + for s in SPECS { + for bad in ['\u{2014}', '\u{2013}'] { + assert!( + !s.usage.contains(bad), + "{}'s usage line contains {bad:?} - user-facing text takes a plain hyphen", + s.name + ); + } + } + } + + /// `--help` is handled centrally, so listing it in a spec would be dead + /// weight that implies it is optional per-command. + #[test] + fn help_is_not_listed_per_command() { + for s in SPECS { + assert!( + !s.flags.contains(&"--help") && !s.flags.contains(&"-h"), + "{} lists --help/-h; they are accepted for every subcommand", + s.name + ); + } + } + + /// Duplicate entries would make the table's behaviour depend on order. + #[test] + fn no_duplicate_specs() { + let mut names: Vec<&str> = SPECS.iter().map(|s| s.name).collect(); + names.sort_unstable(); + let before = names.len(); + names.dedup(); + assert_eq!(before, names.len(), "duplicate subcommand in SPECS"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 2c9b1c477..892432bbb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; +pub mod cli_flags; pub mod coding_agent_session_ingest; pub mod config; pub mod daily_plan; diff --git a/src/main.rs b/src/main.rs index c01754789..9199876a3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,6 +60,19 @@ async fn main() -> Result<()> { let _ = dotenvy::from_path(home.join(".meridian").join(".env")); } + // 1a-bis. Reject unrecognised flags BEFORE any subcommand can act on them. + // + // Every block below parses argv by searching for tokens it knows, so an + // unknown flag used to contribute nothing and leave the command running + // its DEFAULTS. `meridian coding-agent-summarise --help` therefore + // summarised and persisted real rows (reported from production + // 2026-08-27), and `meridian worklog-post-approved --help` would have + // posted approved worklogs to a customer's real tracker. This runs + // ahead of the whole chain, so no side effect is reachable through a + // typo. See `meridian::cli_flags` for the table and the test that keeps + // new subcommands from slipping past it. + meridian::cli_flags::enforce(); + // 1b. Subcommand dispatch. `meridian coding-agent-hook` is the Claude Code // SessionEnd hook entry point: one-shot, reads a JSON payload on stdin, // seals that session, exits 0. It must stay light (no daemon init, no From 8eb42055c9998c77f9362f4e8ebf7a5dc54d8f56 Mon Sep 17 00:00:00 2001 From: adityaharishch Date: Tue, 1 Sep 2026 08:49:00 +0530 Subject: [PATCH 30/53] fix(etl): recover from the corruption latch instead of stopping forever `db_corrupt` was a one-way flag: stored `true` in three places and `false` in none, so the first corruption signal stopped the ETL, the task-linker wakeups and capture retention for the life of the process. It also went silent - one error at the moment it tripped, then nothing. Its justification, that the condition "cannot clear without an operator", is false in practice. Reported from production 2026-08-25: a daemon latched at startup against a file that was still being rebuilt, the rebuild finished ~45 minutes later, and the daemon then sat idle against a healthy database for 44.7 hours until a manual restart, whose very first ETL pass succeeded and caught up 12,642 frames. Nothing could have caught it either - the tray watchdog explicitly rules out `etl_runs` as a heartbeat BECAUSE of this latch. It was also invisible: `db::repair::rebuild` clears the `db.corrupt` notice from the file it rebuilds, so the banner - the only user-visible signal - disappears while a still-running daemon stays latched. The dashboard reported a healthy system with a dead ETL. A latched daemon now re-checks every 15 minutes (not every ~60s tick; quick_check reads every page) and resumes when the database is provably sound, clearing its own notice. If it stays damaged it says so on every re-check rather than going quiet. Checked BEFORE health: whether the file was replaced underneath us. A repair rebuilds into a new inode, so re-checking through the existing pool could never observe the fix - and because SQLite addresses -wal and -shm by path, checkpointing or closing that stale pool would write its journal state against the NEW file, which is the v1.80.0 corruption mechanism exactly. On a detected swap the daemon exits without touching the database and lets the service manager restart it. "Could not stat" never counts as "changed". --- src/db/integrity.rs | 41 +++++ src/main.rs | 378 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 402 insertions(+), 17 deletions(-) diff --git a/src/db/integrity.rs b/src/db/integrity.rs index 1932e0d89..f2fb99dd4 100644 --- a/src/db/integrity.rs +++ b/src/db/integrity.rs @@ -353,6 +353,47 @@ async fn scan_one(pool: &SqlitePool, table: &str) -> Result<()> { Ok(()) } +/// Identity of the file currently at `path` - `(device, inode)` on unix, `None` +/// elsewhere or when it cannot be stated. +/// +/// # Why a process needs this +/// +/// `db::repair` rebuilds into a NEW file and renames it over `meridian.db`. A +/// daemon holding a pool open across that swap keeps its connections on the +/// OLD, now-unlinked inode: its reads see the damaged file forever and its +/// writes land somewhere no reader will ever look. Re-running an integrity +/// check through that pool therefore cannot ever observe the repair - it is +/// checking the wrong file. Comparing this value against the one captured at +/// startup is the only way the daemon can notice. +/// +/// Worse than useless-but-harmless: SQLite addresses the `-wal` and `-shm` +/// sidecars **by path, not by inode**, so a stale pool that checkpoints or +/// closes writes its journal state against the NEW file's sidecars. That is +/// the exact mechanism behind this repo's v1.80.0 macOS corruption. A daemon +/// that detects a swap must exit WITHOUT checkpointing or closing. +/// +/// Unix-only by design rather than by omission: the swap this detects requires +/// renaming over a file another process holds open, which Windows refuses +/// outright (os error 32). There is nothing to detect there. +/// +/// # Who calls this +/// `main.rs` captures it at startup and re-checks it while the ETL is latched +/// on [`is_corrupt_error`], the one state in which the file is likely to be +/// replaced underneath a running daemon. +pub fn file_identity(path: &str) -> Option<(u64, u64)> { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let md = std::fs::metadata(path).ok()?; + Some((md.dev(), md.ino())) + } + #[cfg(not(unix))] + { + let _ = path; + None + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/main.rs b/src/main.rs index c01754789..accbe7892 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1377,6 +1377,15 @@ async fn main() -> Result<()> { // stop the daemon anyway — exiting here would just crash-loop under // launchd's KeepAlive. let db_corrupt = Arc::new(std::sync::atomic::AtomicBool::new(false)); + // Identity of meridian.db as this process opened it. Re-checked only while + // latched, to tell "the file was repaired and swapped underneath us" (our + // pool is now on a dead inode) apart from "the same file is still damaged". + // See `integrity::file_identity` for why the distinction is load-bearing. + let db_identity_at_start = meridian::db::integrity::file_identity(&initial_cfg.meridian_db); + // When the swap above is detected, the pool must NOT be checkpointed or + // closed on the way out - SQLite addresses -wal/-shm by path, so a stale + // pool's journal state would land on the NEW file. Set only on that path. + let mut db_file_swapped = false; { let meridian = meridian.clone(); let db_corrupt = db_corrupt.clone(); @@ -1545,6 +1554,11 @@ async fn main() -> Result<()> { // Track the last-applied log level so we can detect changes and hot-reload // the EnvFilter without restarting the daemon. let mut last_log_level = initial_cfg.runtime.log_level.clone(); + // Paces the latched-state re-check. `None` = never checked, so the first + // latched tick re-checks immediately rather than waiting out a full + // interval - a daemon that latches during startup (the reported case) then + // recovers as soon as the rebuild it raced finishes. + let mut last_corrupt_check: Option = None; loop { // Determine the sleep duration from the current settings.json before sleeping. @@ -1579,10 +1593,30 @@ async fn main() -> Result<()> { *etl_tick_span.lock().unwrap_or_else(|e| e.into_inner()) = Some(poll_tick.clone()); let _guard = poll_tick.enter(); tracing::debug!("starting ETL tick"); - // Latched, never re-tried — see DB_CORRUPT_NOTICE. Everything - // else in the tick (PM sync, notifications, plan nudge) reads - // tables corruption may not have touched, so the daemon stays - // useful instead of exiting. + // Latched on corruption — but NOT forever. Everything else in + // the tick (PM sync, notifications, plan nudge) reads tables + // corruption may not have touched, so the daemon stays useful + // instead of exiting; the ETL itself waits for the database to + // be provably healthy again. See `recover_from_corruption`. + if db_corrupt.load(std::sync::atomic::Ordering::Relaxed) { + match recover_from_corruption( + &meridian, + &initial_cfg.meridian_db, + db_identity_at_start, + &mut last_corrupt_check, + ) + .await + { + CorruptionRecheck::Waiting => {} + CorruptionRecheck::Recovered => { + db_corrupt.store(false, std::sync::atomic::Ordering::Relaxed); + } + CorruptionRecheck::FileSwapped => { + db_file_swapped = true; + break; + } + } + } if !db_corrupt.load(std::sync::atomic::Ordering::Relaxed) { if etl_tick(&meridian).await { db_corrupt.store(true, std::sync::atomic::Ordering::Relaxed); @@ -1729,21 +1763,37 @@ async fn main() -> Result<()> { // the entire question when a `meridian.db` is later found malformed. A // corruption investigation on 2026-08-25 stalled on exactly this ambiguity // and had to withdraw its conclusion. Now silence here means killed. - match meridian::db::meridian::checkpoint_wal(&meridian).await { - Ok(()) => tracing::info!( - pid = std::process::id() as i64, - "WAL checkpoint on shutdown complete" - ), - // `pid` here too: the whole reason both outcomes are logged is to - // attribute a checkpoint to a generation, and a FAILED checkpoint is - // the one most worth attributing. - Err(e) => tracing::warn!( + // + // Skipped ENTIRELY when the database file was replaced underneath us. The + // pool's connections are on the old, unlinked inode, but SQLite addresses + // the `-wal` and `-shm` sidecars BY PATH - so checkpointing or closing here + // would write this process's stale journal state against the NEW file. That + // is not a lesser version of the corruption this checkpoint prevents, it is + // precisely the v1.80.0 mechanism. Leaking the pool on the way out of a + // process that is exiting anyway costs nothing by comparison. + if db_file_swapped { + tracing::error!( pid = std::process::id() as i64, - error = %meridian::errors::chain(&e), - "WAL checkpoint on shutdown failed - continuing anyway" - ), + "exiting without a WAL checkpoint: meridian.db was replaced underneath this \ + process, so this pool's journal state must not be written back" + ); + } else { + match meridian::db::meridian::checkpoint_wal(&meridian).await { + Ok(()) => tracing::info!( + pid = std::process::id() as i64, + "WAL checkpoint on shutdown complete" + ), + // `pid` here too: the whole reason both outcomes are logged is to + // attribute a checkpoint to a generation, and a FAILED checkpoint is + // the one most worth attributing. + Err(e) => tracing::warn!( + pid = std::process::id() as i64, + error = %meridian::errors::chain(&e), + "WAL checkpoint on shutdown failed - continuing anyway" + ), + } + meridian.close().await; } - meridian.close().await; // Flush OTel exporters FIRST, while the runtime is alive — this writes the // daemon's final shutdown spans/logs into the spool's pending/ dir... @@ -1758,6 +1808,140 @@ async fn main() -> Result<()> { Ok(()) } +/// How long a latched daemon waits between re-checks of whether the database +/// has become healthy underneath it. +/// +/// Far slower than the ~60 s poll tick on purpose. The original latch existed +/// because "retrying only re-reads damaged pages", and that reasoning is sound +/// for the ETL itself - `quick_check` reads every page in the file, which is +/// multi-second on a multi-GB database. Fifteen minutes keeps that cost +/// negligible while bounding an outage at minutes instead of "until a human +/// notices and restarts the daemon", which in the reported incident was 44.7 +/// hours. +const CORRUPT_RECHECK_EVERY: std::time::Duration = std::time::Duration::from_secs(900); + +/// What a latched daemon should do after re-examining its database. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +enum CorruptionRecheck { + /// Still damaged, or not yet due for a re-check. Stay latched. + Waiting, + /// The database is provably sound again - resume the ETL. + Recovered, + /// The file was replaced underneath this process (a repair rebuilt and + /// renamed it). The pool is on a dead inode; the daemon must exit WITHOUT + /// touching the database so launchd restarts it against the new file. + FileSwapped, +} + +/// Re-examine a database the ETL has latched on, and say whether to resume. +/// +/// # The bug this closes +/// +/// `db_corrupt` used to be a one-way flag: set on the first corruption signal +/// and never cleared for the life of the process. That silently disabled the +/// ETL, the task-linker wakeups and capture retention, and emitted nothing +/// after the single error that tripped it. The justification - that the +/// condition "cannot clear without an operator" - is false in practice, and +/// production disproved it on 2026-08-25: a daemon latched at startup against +/// a file that was still being rebuilt, the rebuild finished ~45 minutes +/// later, and the daemon then sat idle against a perfectly healthy database +/// for 44.7 hours until someone restarted it by hand. The very first ETL pass +/// after that restart succeeded, catching up 12,642 frames. +/// +/// It was also unobservable from outside: `db::repair::rebuild` clears the +/// `db.corrupt` notice from the file it rebuilds, so the banner - the only +/// user-visible signal - disappears while a still-running daemon stays +/// latched. The dashboard reported a healthy system with a dead ETL. +/// +/// # Why the file-identity check comes first +/// +/// A repair rebuilds into a new file and renames it into place, so the +/// recovered database is a DIFFERENT inode. Re-running `quick_check` through +/// the existing pool would keep examining the old, unlinked one and could +/// never observe the fix. Worse, that pool must not be checkpointed or closed +/// (see [`meridian::db::integrity::file_identity`]). So identity is checked +/// before health, and a swap short-circuits to [`CorruptionRecheck::FileSwapped`]. +/// +/// # Safety of resuming +/// +/// `quick_check` returning clean is not a promise the database is perfect - it +/// skips the index-vs-table checks `integrity_check` performs. It does not +/// need to be a promise: if damage remains, the next ETL pass hits it and +/// `etl_tick` latches again, costing one pass. The failure is self-correcting +/// in the safe direction, which is what makes resuming reasonable at all. +async fn recover_from_corruption( + pool: &sqlx::SqlitePool, + db_path: &str, + identity_at_start: Option<(u64, u64)>, + last_check: &mut Option, +) -> CorruptionRecheck { + let now = std::time::Instant::now(); + if let Some(prev) = *last_check { + if now.duration_since(prev) < CORRUPT_RECHECK_EVERY { + return CorruptionRecheck::Waiting; + } + } + *last_check = Some(now); + + // Only meaningful when both readings exist: `file_identity` is `None` on + // non-unix and on a stat failure, and "could not tell" must never be read + // as "it changed" - that would exit the daemon on a transient stat error. + let identity_now = meridian::db::integrity::file_identity(db_path); + if let (Some(before), Some(after)) = (identity_at_start, identity_now) { + if before != after { + tracing::error!( + db_path, + "meridian.db was replaced underneath this process (a repair rebuilt it) - \ + this pool is on the old, unlinked file and must not write to it. Exiting \ + without checkpointing so the service manager restarts against the new file." + ); + return CorruptionRecheck::FileSwapped; + } + } + + match meridian::db::integrity::quick_check(pool, 20).await { + Ok(problems) if problems.is_empty() => { + tracing::info!( + "meridian.db passes its integrity check again - resuming the ETL (it was \ + stopped when corruption was detected)" + ); + // The daemon raised this notice, so the daemon clears it. Without + // this the banner outlives the fault it reports. + if let Err(e) = + meridian::notices::clear_typed(pool, DB_CORRUPT_NOTICE, DB_CORRUPT_NOTICE).await + { + tracing::warn!( + error = %meridian::errors::chain(&e), + "could not clear the db.corrupt notice after recovery" + ); + } + CorruptionRecheck::Recovered + } + Ok(problems) => { + // WARN every recheck interval, not once. The original latch went + // silent after a single error, which is why 44.7 hours of total + // ETL outage left nothing in `meridian logs` to find. + tracing::warn!( + problem_count = problems.len(), + first = %problems.first().map(String::as_str).unwrap_or_default(), + recheck_s = CORRUPT_RECHECK_EVERY.as_secs(), + "ETL is still stopped: meridian.db remains damaged. Run 'meridian db repair' \ + with Meridian quit." + ); + CorruptionRecheck::Waiting + } + Err(e) => { + // The check failing is not evidence either way, so stay latched - + // but say so, for the same reason as above. + tracing::warn!( + error = %meridian::errors::chain(&e), + "ETL is still stopped: the integrity re-check could not run" + ); + CorruptionRecheck::Waiting + } + } +} + /// Runs one ETL pass and maps the outcome onto the notice bus. /// /// Returns `true` when the failure was database corruption, which the caller @@ -1828,6 +2012,166 @@ async fn etl_tick(meridian: &meridian::db::SqlitePool) -> bool { /// `db::repair` must clear the very same id from a rebuilt database. const DB_CORRUPT_NOTICE: &str = meridian::notices::DB_CORRUPT; +#[cfg(test)] +mod corruption_recovery_tests { + use super::{recover_from_corruption, CorruptionRecheck, CORRUPT_RECHECK_EVERY}; + use sqlx::sqlite::SqliteConnectOptions; + use sqlx::SqlitePool; + use std::str::FromStr; + use std::time::Instant; + + async fn fresh_db() -> SqlitePool { + let opts = SqliteConnectOptions::from_str("sqlite::memory:") + .unwrap() + .create_if_missing(true); + let pool = SqlitePool::connect_with(opts).await.unwrap(); + sqlx::migrate!("src/migrations").run(&pool).await.unwrap(); + pool + } + + /// THE regression. The latch had no way out: `db_corrupt` was stored `true` + /// in three places and `false` in none, so a database that became healthy + /// underneath a running daemon left the ETL stopped for the life of the + /// process - 44.7 hours in the reported incident, ended only by a manual + /// restart whose very first pass succeeded. + #[tokio::test] + async fn a_healthy_database_resumes_the_etl() { + let pool = fresh_db().await; + let mut last = None; + assert_eq!( + recover_from_corruption(&pool, ":memory:", None, &mut last).await, + CorruptionRecheck::Recovered, + "a database that passes its integrity check must un-latch the ETL" + ); + } + + /// Recovery must also retract the banner the daemon itself raised, + /// otherwise the notice outlives the fault it reports. + #[tokio::test] + async fn recovery_clears_the_notice_the_daemon_raised() { + let pool = fresh_db().await; + meridian::notices::raise_typed( + &pool, + meridian::notices::Notice { + id: meridian::notices::DB_CORRUPT, + severity: "error", + title: "Meridian's database is damaged", + detail: "test", + remedy: None, + event_key: meridian::notices::DB_CORRUPT, + deep_link: None, + }, + ) + .await + .unwrap(); + + let mut last = None; + assert_eq!( + recover_from_corruption(&pool, ":memory:", None, &mut last).await, + CorruptionRecheck::Recovered + ); + + let notices = meridian_core::notices::read_notices(&pool).await; + assert!( + !notices + .iter() + .any(|n| n.notice_id == meridian::notices::DB_CORRUPT), + "the db.corrupt banner must not survive the recovery that fixes it" + ); + } + + /// The re-check reads every page of the database, so it must not run on + /// every ~60 s poll tick. The first latched tick checks immediately + /// (`last` is `None`); the next one waits out the interval. + #[tokio::test] + async fn the_recheck_is_throttled() { + let pool = fresh_db().await; + let mut last = Some(Instant::now()); + assert_eq!( + recover_from_corruption(&pool, ":memory:", None, &mut last).await, + CorruptionRecheck::Waiting, + "a re-check ran before its interval elapsed - this reads every page in the file" + ); + + // Once the interval has passed it runs again and, on a clean database, + // recovers. Drives the clock rather than sleeping 15 minutes. + last = Some(Instant::now() - CORRUPT_RECHECK_EVERY); + assert_eq!( + recover_from_corruption(&pool, ":memory:", None, &mut last).await, + CorruptionRecheck::Recovered + ); + } + + /// A repair rebuilds into a NEW file and renames it into place, so the pool + /// is left on the old, unlinked inode. Re-checking through it could never + /// see the fix, and - the dangerous half - checkpointing or closing it + /// writes stale journal state against the NEW file's `-wal`/`-shm`, which + /// is the v1.80.0 corruption mechanism exactly. + #[cfg(unix)] + #[tokio::test] + async fn a_replaced_file_is_detected_rather_than_re_checked() { + let pool = fresh_db().await; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("meridian.db"); + std::fs::write(&path, b"first").unwrap(); + let before = meridian::db::integrity::file_identity(path.to_str().unwrap()); + assert!(before.is_some(), "identity must be readable on unix"); + + // Replace it the way a repair does: a different file renamed into place. + let replacement = dir.path().join("rebuilt"); + std::fs::write(&replacement, b"second").unwrap(); + std::fs::rename(&replacement, &path).unwrap(); + + let mut last = None; + assert_eq!( + recover_from_corruption(&pool, path.to_str().unwrap(), before, &mut last).await, + CorruptionRecheck::FileSwapped, + "a swapped database must be detected, not re-checked through the dead pool" + ); + } + + /// The same file must NOT read as swapped, or a healthy latched daemon + /// would exit every re-check interval. + #[cfg(unix)] + #[tokio::test] + async fn an_unchanged_file_is_not_mistaken_for_a_swap() { + let pool = fresh_db().await; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("meridian.db"); + std::fs::write(&path, b"same").unwrap(); + let ident = meridian::db::integrity::file_identity(path.to_str().unwrap()); + + let mut last = None; + assert_ne!( + recover_from_corruption(&pool, path.to_str().unwrap(), ident, &mut last).await, + CorruptionRecheck::FileSwapped, + "an unchanged file read as swapped - the daemon would exit on every re-check" + ); + } + + /// "Could not tell" must never be read as "it changed". `file_identity` + /// returns `None` on a stat failure and on non-unix; treating that as a + /// swap would exit the daemon on a transient error, or on every re-check + /// on Windows. + #[tokio::test] + async fn an_unreadable_identity_never_reads_as_swapped() { + let pool = fresh_db().await; + let mut last = None; + let outcome = recover_from_corruption( + &pool, + "/nonexistent/path/that/cannot/be/stated.db", + Some((1, 2)), + &mut last, + ) + .await; + assert_ne!( + outcome, + CorruptionRecheck::FileSwapped, + "an unreadable identity must not be treated as evidence of a swap" + ); + } +} + #[cfg(test)] mod startup_order_tests { /// The single-instance guard's CHECK must run before `setup_db` opens the From 3fe39c4983c76673abd7e6c4b3d31fd3a678d809 Mon Sep 17 00:00:00 2001 From: adityaharishch Date: Tue, 1 Sep 2026 09:04:22 +0530 Subject: [PATCH 31/53] fix(summariser): bound rate-limited retries and pace a failing queue Two independent defects behind the same production symptom: a queue that sat flat at 59-61 rows for 46 hours while the daemon forked 18,674 times (one every ~9 seconds, continuously). Immortal rows. Of drain()'s three outcome branches, `rate_limited` was the one that never recorded an attempt, so MAX_ROW_ATTEMPTS did not apply to it and such a row was retried forever. That is not theoretical: RATE_LIMIT_MARKERS matches bare substrings including "429" and "quota" anywhere in an engine's output, and this is a developer tool whose transcripts are full of both, so a row failing for an unrelated reason can be misfiled as rate-limited and then never terminate. Consecutive rate-limited reports are now counted against their own, far more generous cap (~a day of 30-minute backoff) before dead-lettering, which keeps the "a quota refills, wait it out" intent while making immortality impossible. The classifier itself is deliberately left alone. Narrowing those markers risks MISSING a genuine limit, which would dead-letter legitimate rows against an exhausted quota - a worse failure than the one being fixed, and no longer necessary now that the accounting bounds it either way. Fork storm. The loop could not tell "nothing to do" from "everything I tried failed", so a batch of 32 fast-failing rows (2 spawns each) was re-attempted every 5 seconds forever. drain() now reports what it did and the wait backs off exponentially, capped at 5 minutes, whenever work was attempted and nothing succeeded. Note this is the opposite of the reported diagnosis: a row that HANGS produces few forks, since it waits in wait_with_output rather than spawn, so a high fork rate is evidence of fast failures and pacing is the fix. Also corrects the wait signal itself. The old `all_backed_off` bool required skipped_backoff == rows.len(), so one failing row alongside backed-off ones collapsed the 30-minute rate-limit wait back to the 5-second sweep - which is what let both defects run in the same batch. --- .../summariser/mod.rs | 344 ++++++++++++++++-- 1 file changed, 323 insertions(+), 21 deletions(-) diff --git a/src/coding_agent_session_ingest/summariser/mod.rs b/src/coding_agent_session_ingest/summariser/mod.rs index bc22f90af..bf2f94901 100644 --- a/src/coding_agent_session_ingest/summariser/mod.rs +++ b/src/coding_agent_session_ingest/summariser/mod.rs @@ -11,7 +11,11 @@ // Escalation, in order: // 1. the session's OWN agent CLI, `primary_attempts` times // 2. a rate limit → stop immediately, back the source off, retry on a later -// tick. A quota refills; it is waited out, never routed around. +// tick. A quota refills; it is waited out, never routed around — but only +// up to MAX_RATE_LIMITED_ROW_ATTEMPTS (~a day of backoff), because this +// branch used to be the one outcome that never recorded an attempt, so a +// row misclassified as rate-limited retried forever and the queue never +// drained. See that constant. // 3. otherwise (crashed / not installed / signed out / unusable output) → the // user's globally chosen AI provider, once (`fallback`). Usually the same // CLI, in which case it is skipped without a call. @@ -565,6 +569,79 @@ pub(crate) fn cap_transcript(transcript: &str, cap: usize) -> String { // ──────────────────────── Loop ────────────────────────────────────────────── +/// What one [`drain`] pass actually did, so the loop can pace itself. +/// +/// Replaces a bare `bool` ("were all rows backed off?") that could not +/// distinguish the three cases the wait needs to tell apart: real progress, a +/// quota wall, and *work attempted that failed*. The old signal also +/// miscounted - it compared `skipped_backoff` against the whole batch, so a +/// single failing row alongside backed-off ones collapsed the 30-minute +/// rate-limit wait back to the 5-second sweep. +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] +struct DrainOutcome { + /// Rows fetched for consideration this pass. + seen: u32, + /// Rows summarised and persisted. + summarised: u32, + /// Rows not attempted because their source is in rate-limit backoff, plus + /// rows whose attempt reported a rate limit. + skipped_backoff: u32, + /// Rows attempted that failed for a non-rate-limit reason. + failed: u32, + /// Rows not attempted because they have exhausted an attempt cap. + skipped_exhausted: u32, +} + +/// Longest the loop will wait when nothing is succeeding. +/// +/// Bounds the retry storm without letting a transient outage park the queue +/// for the full rate-limit backoff: a pass that fails is retried within five +/// minutes, not five seconds. +const MAX_IDLE_BACKOFF_SECS: u64 = 300; + +/// Seconds to wait before the next pass, given what the last one did. +/// +/// # The fork storm this bounds +/// +/// Measured in production over ~46 hours: 18,674 forks, one every ~9 seconds, +/// continuously, while the queue sat flat at 59-61 rows. The shape is a batch +/// of `batch_per_tick` (32) rows that each fail FAST - two primary attempts, +/// so 64 spawns - re-attempted every `sweep_interval_secs` (5 s). Nothing in +/// the loop distinguished "nothing to do" from "everything I tried failed", so +/// a failing queue was retried at the same cadence as an idle one forever. +/// +/// Note this is the opposite of the reported diagnosis. A row that *hangs* +/// produces FEW forks - it sits in `wait_with_output`, not in `spawn` - so a +/// high fork rate is positive evidence of fast failures, and pacing them is +/// the fix rather than a timeout change. +fn next_wait_secs(out: DrainOutcome, cfg: &SummariserConfig, consecutive_idle: u32) -> u64 { + // Progress, or nothing pending: keep the responsive cadence. An empty + // queue costs nothing to re-check, and the indexer's notify wakes it + // sooner anyway. + if out.summarised > 0 || out.seen == 0 { + return cfg.sweep_interval_secs; + } + // Everything that could have been worked on is waiting out a quota, and + // nothing failed for another reason. Wait out the backoff properly - this + // is the case the old `all_backed_off` flag was trying to express. + if out.failed == 0 && out.skipped_backoff > 0 { + return cfg.rate_limit_backoff_secs; + } + // Rows were attempted and none succeeded. Back off exponentially from the + // normal sweep so a wedged queue costs a fork every few minutes instead of + // 64 every few seconds. Capped, so recovery is still prompt. + if out.failed > 0 { + let factor = 1u64 << consecutive_idle.min(6); + return cfg + .sweep_interval_secs + .saturating_mul(factor) + .min(MAX_IDLE_BACKOFF_SECS); + } + // Everything left is exhausted (dead-lettered or capped) - there is + // nothing to retry until new rows seal, and the notify covers that. + cfg.sweep_interval_secs +} + /// The summariser task: drain the queue, then wait for an indexer notify or the /// catch-up sweep. Dormant if no coding agent is present. Backs off when the /// primary engine is unavailable (a failed row stays pending for a later drain). @@ -594,15 +671,28 @@ pub async fn run_loop( // engine is available again. Keyed on app_name ("Claude Code", "Codex", …). // Only rows whose source is in backoff are skipped; other sources continue. let mut source_backoff: HashMap = HashMap::new(); + // Separate ledger for consecutive rate-limited reports — see + // `MAX_RATE_LIMITED_ROW_ATTEMPTS`. + let mut rate_limited_attempts: HashMap = HashMap::new(); + // Consecutive passes that attempted work and summarised nothing. Drives + // `next_wait_secs`'s exponential backoff so a wedged queue stops re-forking + // its whole batch every few seconds. + let mut consecutive_idle: u32 = 0; loop { - let all_backed_off = drain(&pool, &cfg, &mut attempts, &mut source_backoff).await; - let wait = if all_backed_off { - // Nothing could be processed (all pending rows are from backed-off - // sources). Wait out the backoff period rather than spinning. - cfg.rate_limit_backoff_secs + let out = drain( + &pool, + &cfg, + &mut attempts, + &mut rate_limited_attempts, + &mut source_backoff, + ) + .await; + if out.summarised > 0 || out.failed == 0 { + consecutive_idle = 0; } else { - cfg.sweep_interval_secs - }; + consecutive_idle = consecutive_idle.saturating_add(1); + } + let wait = next_wait_secs(out, &cfg, consecutive_idle); tokio::select! { _ = shutdown_rx.changed() => break, _ = notify.notified() => {} @@ -642,8 +732,9 @@ async fn drain( pool: &SqlitePool, cfg: &SummariserConfig, attempts: &mut HashMap, + rate_limited_attempts: &mut HashMap, source_backoff: &mut HashMap, -) -> bool { +) -> DrainOutcome { // Expire stale backoffs before deciding what to skip. let now_instant = std::time::Instant::now(); source_backoff.retain(|_, until| *until > now_instant); @@ -659,43 +750,82 @@ async fn drain( Ok(r) => r, Err(e) => { tracing::warn!(error = %crate::errors::chain(&e), "summariser: fetch_pending failed"); - return false; + return DrainOutcome::default(); } }; if rows.is_empty() { - return false; + return DrainOutcome::default(); } - let mut summarised = 0u32; - let mut skipped_backoff = 0u32; + let mut out = DrainOutcome { + seen: rows.len() as u32, + ..DrainOutcome::default() + }; for row in &rows { // Skip rows whose agent source is still in rate-limit backoff. if source_backoff.contains_key(&row.agent) { - skipped_backoff += 1; + out.skipped_backoff += 1; continue; } let tries = attempts.get(&row.id).copied().unwrap_or(0); if tries >= MAX_ROW_ATTEMPTS { + out.skipped_exhausted += 1; continue; // dead-lettered this daemon lifetime } + if rate_limited_attempts.get(&row.id).copied().unwrap_or(0) >= MAX_RATE_LIMITED_ROW_ATTEMPTS + { + out.skipped_exhausted += 1; + continue; // gave up on this row's endless "rate limited" reports + } let outcome = summarise_one(pool, row, cfg, true).await; if outcome.written { - summarised += 1; + out.summarised += 1; // Row succeeded and moved past pending_summariser — drop its // ledger entry via record_attempt so `attempts` only ever holds // currently-pending rows that have failed at least once, not // every row that ever failed once over the daemon's lifetime. record_attempt(attempts, row.id, true); + // Same for the rate-limit ledger: a success means whatever quota + // was in the way has refilled, so the row starts clean if it is + // ever retried. + rate_limited_attempts.remove(&row.id); } else if outcome.rate_limited { // Apply per-source backoff so other sources can still drain. let until = std::time::Instant::now() + Duration::from_secs(cfg.rate_limit_backoff_secs); source_backoff.insert(row.agent.clone(), until); - skipped_backoff += 1; + out.skipped_backoff += 1; + // Count it. This branch used to be the ONE outcome that never + // touched the ledger, which made `MAX_ROW_ATTEMPTS` inapplicable to + // it: a row that classified `RateLimited` on every attempt was + // retried forever and could never dead-letter. That is not a + // theoretical hole - `RATE_LIMIT_MARKERS` matches bare substrings + // like "429" and "quota" anywhere in an engine's output, and this + // is a developer tool whose transcripts are full of both, so a row + // failing for an unrelated reason can be misfiled as rate-limited + // and then never terminate. + // + // Counted against its OWN, much more generous cap rather than + // `MAX_ROW_ATTEMPTS`, because the intent behind this branch is + // still right: a quota refills, and it should be waited out rather + // than routed around. See `MAX_RATE_LIMITED_ROW_ATTEMPTS`. + let tries = record_rate_limited_attempt(rate_limited_attempts, row.id); + if tries >= MAX_RATE_LIMITED_ROW_ATTEMPTS { + if let Err(e) = db::write_dead_letter(pool, row.id).await { + tracing::error!(row_id = row.id, error = %crate::errors::chain(&e), "failed to dead-letter a persistently rate-limited row"); + } + tracing::warn!( + row_id = row.id, + attempts = tries, + "row has reported rate-limited on every attempt for far longer than a \ + quota takes to refill - dead-lettering it rather than retrying forever \ + (it may be a misclassified failure)" + ); + } tracing::warn!( row_id = outcome.row_id, source = %row.agent, @@ -704,6 +834,7 @@ async fn drain( ); } else { // Transient failure. Leave pending for retry; log so it isn't silent. + out.failed += 1; let tries = record_attempt(attempts, row.id, false) .expect("record_attempt(.., written=false) always returns Some"); if tries >= MAX_ROW_ATTEMPTS { @@ -727,13 +858,11 @@ async fn drain( } } - if summarised > 0 { - tracing::info!(summarised, "summariser drain"); + if out.summarised > 0 { + tracing::info!(summarised = out.summarised, "summariser drain"); } - // Signal global backoff only when ALL rows were skipped due to source - // backoffs — nothing useful can be done until at least one source recovers. - skipped_backoff > 0 && skipped_backoff == rows.len() as u32 && summarised == 0 + out } /// Updates the per-row failure ledger (`attempts`, see [`MAX_ROW_ATTEMPTS`]) @@ -748,6 +877,28 @@ async fn drain( /// On failure (`written = false`) the row's attempt count is incremented and /// the new count returned as `Some(tries)`, for the caller to compare against /// [`MAX_ROW_ATTEMPTS`]. +/// How many consecutive `RateLimited` outcomes a single row may report before +/// it is dead-lettered anyway. +/// +/// Deliberately far larger than [`MAX_ROW_ATTEMPTS`], and paced by +/// `rate_limit_backoff_secs` (30 minutes by default) rather than the 5-second +/// sweep - so this is roughly a day of genuinely waiting out a quota before +/// giving up. A real usage limit refills long inside that; a row still +/// reporting "rate limited" a day later is almost certainly a misclassified +/// failure, and the alternative to giving up is retrying it forever. +const MAX_RATE_LIMITED_ROW_ATTEMPTS: u32 = 48; + +/// Increment and return a row's consecutive rate-limited count. +/// +/// Kept in a ledger separate from [`record_attempt`]'s so the two caps cannot +/// interfere: a row that fails twice, then hits a genuine quota, must not +/// arrive at the rate-limit cap carrying unrelated strikes. +fn record_rate_limited_attempt(attempts: &mut HashMap, row_id: i64) -> u32 { + let tries = attempts.get(&row_id).copied().unwrap_or(0) + 1; + attempts.insert(row_id, tries); + tries +} + fn record_attempt(attempts: &mut HashMap, row_id: i64, written: bool) -> Option { if written { attempts.remove(&row_id); @@ -946,4 +1097,155 @@ mod tests { "row 2's independent failure count must be untouched" ); } + + // ── pacing + the rate-limit cap ───────────────────────────────────────── + + fn cfg_for_pacing() -> SummariserConfig { + let mut c = SummariserConfig::from_env(); + c.sweep_interval_secs = 5; + c.rate_limit_backoff_secs = 1800; + c + } + + /// THE fork storm. Measured in production: 18,674 forks over ~46 hours, + /// one every ~9 s, while the queue sat flat. A batch of 32 rows that each + /// fail fast costs 64 spawns, and it used to be re-attempted every 5 s + /// forever because the loop could not tell "nothing to do" from + /// "everything I tried failed". + #[test] + fn a_failing_pass_backs_off_instead_of_re_forking_every_sweep() { + let cfg = cfg_for_pacing(); + let failing = DrainOutcome { + seen: 32, + failed: 32, + ..DrainOutcome::default() + }; + let mut prev = 0; + for idle in 1..=6 { + let w = next_wait_secs(failing, &cfg, idle); + assert!( + w > prev, + "wait must grow while nothing succeeds (idle={idle} gave {w}, previous {prev})" + ); + prev = w; + } + assert!( + next_wait_secs(failing, &cfg, 1) > cfg.sweep_interval_secs, + "the first failing pass must already back off past the normal sweep" + ); + assert_eq!( + next_wait_secs(failing, &cfg, 99), + MAX_IDLE_BACKOFF_SECS, + "backoff must cap so recovery stays prompt" + ); + } + + /// Progress and an empty queue both keep the responsive cadence - backing + /// off a working summariser would delay every summary. + #[test] + fn progress_and_idleness_keep_the_normal_sweep() { + let cfg = cfg_for_pacing(); + let progressing = DrainOutcome { + seen: 4, + summarised: 4, + ..DrainOutcome::default() + }; + assert_eq!( + next_wait_secs(progressing, &cfg, 3), + cfg.sweep_interval_secs, + "a pass that summarised must not inherit an earlier backoff" + ); + assert_eq!( + next_wait_secs(DrainOutcome::default(), &cfg, 3), + cfg.sweep_interval_secs, + "an empty queue is cheap to re-check" + ); + } + + /// A genuine quota wall waits the full backoff rather than spinning. + #[test] + fn an_entirely_rate_limited_pass_waits_out_the_quota() { + let cfg = cfg_for_pacing(); + let walled = DrainOutcome { + seen: 6, + skipped_backoff: 6, + ..DrainOutcome::default() + }; + assert_eq!( + next_wait_secs(walled, &cfg, 0), + cfg.rate_limit_backoff_secs, + "nothing can proceed until a quota refills - do not spin on it" + ); + } + + /// The old `all_backed_off` flag required `skipped_backoff == rows.len()`, + /// so ONE failing row alongside backed-off ones collapsed the 30-minute + /// wait back to the 5-second sweep. It must still not spin - but it also + /// must not wait out the full quota, because the failing row is real work + /// that deserves a retry sooner than that. + #[test] + fn one_failure_beside_backed_off_rows_neither_spins_nor_stalls() { + let cfg = cfg_for_pacing(); + let mixed = DrainOutcome { + seen: 10, + skipped_backoff: 9, + failed: 1, + ..DrainOutcome::default() + }; + let w = next_wait_secs(mixed, &cfg, 1); + assert!( + w > cfg.sweep_interval_secs, + "a mixed pass collapsed back to the bare sweep - this is the spin" + ); + assert!( + w < cfg.rate_limit_backoff_secs, + "the failing row must be retried well before the quota window elapses" + ); + } + + /// THE immortality bug. The `rate_limited` branch was the one outcome that + /// never touched a ledger, so `MAX_ROW_ATTEMPTS` did not apply to it and a + /// row misclassified as rate-limited retried forever. `RATE_LIMIT_MARKERS` + /// matches bare "429"/"quota" anywhere in an engine's output, and this is a + /// developer tool whose transcripts are full of both. + #[test] + fn a_persistently_rate_limited_row_eventually_terminates() { + let mut ledger: HashMap = HashMap::new(); + let mut last = 0; + for _ in 0..MAX_RATE_LIMITED_ROW_ATTEMPTS { + last = record_rate_limited_attempt(&mut ledger, 7); + } + assert_eq!( + last, MAX_RATE_LIMITED_ROW_ATTEMPTS, + "consecutive rate-limited reports must be counted, not ignored" + ); + } + + /// The two caps are independent: a row that failed twice and then hits a + /// genuine quota must not arrive at the rate-limit cap carrying unrelated + /// strikes, and vice versa. + #[test] + fn the_two_attempt_ledgers_do_not_bleed_into_each_other() { + let mut failures: HashMap = HashMap::new(); + let mut rate_limits: HashMap = HashMap::new(); + record_attempt(&mut failures, 42, false); + record_attempt(&mut failures, 42, false); + assert_eq!(record_rate_limited_attempt(&mut rate_limits, 42), 1); + assert_eq!(failures.get(&42), Some(&2)); + } + + /// Waiting out a quota must be generous - far longer than the fast-failure + /// cap - or a real usage limit would dead-letter a legitimate row. + #[test] + fn the_rate_limit_cap_is_far_more_patient_than_the_failure_cap() { + // Read through locals so this is a value comparison rather than an + // assertion on a constant expression (which clippy rejects). + let patient: u32 = MAX_RATE_LIMITED_ROW_ATTEMPTS; + let strict: u32 = MAX_ROW_ATTEMPTS; + assert!( + patient > strict.saturating_mul(10), + "a refilling quota must be waited out, not treated like a broken row \ + (rate-limit cap {patient}, failure cap {strict})" + ); + } } From b64b780ee0ae9ef1c8ef83c250f398c2e0594636 Mon Sep 17 00:00:00 2001 From: adityaharishch Date: Tue, 1 Sep 2026 09:15:09 +0530 Subject: [PATCH 32/53] fix(cli): add the restart subcommand five remedies already recommend `meridian restart` did not exist in this binary, while five places in it told users to run it: doctor's etl-freshness remedy, the Jira and Trello OAuth handlers' SUCCESS output ("Tokens saved - run `meridian restart` to pick them up"), diagnose's expired-token remedy, and capture's persistent-failure remedy. It survived because it works for whoever wrote it. A source install symlinks ~/.local/bin/meridian at scripts/meridian-cli.sh, whose bash cmd_restart has always existed; a DMG install runs this native binary and got `unknown subcommand "restart"`. The OAuth case is the damaging one and has nothing to do with doctor: a packaged user connects Jira, is told to run a command that does not exist, and their tracker integration silently never picks up the tokens. Uses `launchctl kickstart -k` on macOS and schtasks /End + /Run on Windows. `-k` is right here and wrong in the tray's watchdog for two reasons: the user asked for it, and this is a separate short-lived process rather than the daemon signalling itself. Deliberately not bootout+bootstrap - the `launchctl disable` that travels with it has already wedged a plist in this repo badly enough to need a manual reinstall. A source checkout with no registered service is told so rather than getting a silent exit 0 that looks like a restart. Two neighbouring remedies pointed at `meridian start`, which also does not exist; kickstart covers stopped-and-loaded too, so they now say restart. Also fixes the discoverability half of the logging report: doctor now points at `meridian logs -f` and notes that the files in ~/.meridian/logs are normally empty. Those files are a crash-only safety net by design and every structured log goes to the telemetry spool - but nothing said so, so an operator debugging a stalled ETL read 0 bytes, concluded logging was broken, and never ran the one tool that would have shown the fault. --- src/cli_flags.rs | 5 + src/health/diagnose.rs | 13 ++- src/health/mod.rs | 16 +++ src/health/platform.rs | 5 +- src/lib.rs | 1 + src/main.rs | 24 ++++ src/restart.rs | 250 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 309 insertions(+), 5 deletions(-) create mode 100644 src/restart.rs diff --git a/src/cli_flags.rs b/src/cli_flags.rs index 1bdf43b8e..693f2b053 100644 --- a/src/cli_flags.rs +++ b/src/cli_flags.rs @@ -255,6 +255,11 @@ pub const SPECS: &[Spec] = &[ usage: "meridian telemetry [--out PATH] [--since MICROS] \ [--endpoint URL] [--auth BASE64]", }, + Spec { + name: "restart", + flags: &[], + usage: "meridian restart (restarts the background daemon)", + }, Spec { name: "logs", flags: &["--min-severity", "--service", "-n", "-f"], diff --git a/src/health/diagnose.rs b/src/health/diagnose.rs index 99a7a0dce..a026efb5e 100644 --- a/src/health/diagnose.rs +++ b/src/health/diagnose.rs @@ -97,7 +97,7 @@ pub fn root_causes(report: &Report) -> Vec { ("jira", "ticket sync"), ("worklog", "hour ledger"), ]), - action: "`meridian start` (or `meridian doctor --fix`).".into(), + action: "`meridian restart` (or `meridian doctor --fix`).".into(), }); } @@ -194,11 +194,20 @@ pub fn escalation_hint(color: bool) -> String { }; let bold = |s: &str| paint("\x1b[1m", s); let dim = |s: &str| paint("\x1b[2m", s); + // The `meridian logs` line is here because an operator debugging a stalled + // ETL on 2026-08-27 went to the paths in the launchd plist, found + // `daemon.log` at 0 bytes, and reported that logging was broken. It is not: + // those files are a crash-only safety net by design and every structured + // log goes to the telemetry spool, which `meridian logs` decodes. Nothing + // told them that, so the one tool that would have shown the fault went + // unused for 44.7 hours. format!( - "\n {}\n • {} {}\n • {}\n", + "\n {}\n • {} {}\n • {} {}\n • {}\n", bold("Still stuck?"), "meridian doctor --fix", dim("attempt automatic + guided repair"), + "meridian logs -f", + dim("live daemon logs (the files in ~/.meridian/logs are normally empty)"), dim("share this output with the team, or run: claude \"debug my meridian doctor output\""), ) } diff --git a/src/health/mod.rs b/src/health/mod.rs index 1fcc1c92e..4ebedae70 100644 --- a/src/health/mod.rs +++ b/src/health/mod.rs @@ -247,6 +247,22 @@ impl Report { _ => paint("\x1b[32m", " ✓ all systems healthy"), }; out.push_str(&format!("{verdict}\n")); + // Where to look next. Reported from production 2026-08-27: an operator + // debugging a stalled ETL went to the paths in the launchd plist + // (`~/.meridian/logs/daemon.log`), found 0 bytes, and concluded logging + // was broken. It is not - those files are a crash-only safety net by + // design, and every structured log goes to the telemetry spool, which + // `meridian logs` decodes. Nothing anywhere told them that, so the one + // tool that would have shown the fault went unused. Printed + // unconditionally rather than only on a warning: the times you most + // need it are the times `doctor` looks fine and something is still off. + out.push_str(&format!( + "\n {}\n", + dim( + "Logs: meridian logs [-f] (the files in ~/.meridian/logs are a crash-only \ + safety net and are normally empty)" + ) + )); out } diff --git a/src/health/platform.rs b/src/health/platform.rs index 82195f322..394e27b09 100644 --- a/src/health/platform.rs +++ b/src/health/platform.rs @@ -181,9 +181,8 @@ pub fn daemon_service() -> Vec { use crate::platform::ServiceStatus; let run_check = match crate::platform::service_status(LABEL_DAEMON) { ServiceStatus::Running(pid) => Check::ok("daemon running", "system", format!("pid {pid}")), - ServiceStatus::NotRunning => { - Check::critical("daemon running", "system", "not loaded").with_remedy("meridian start") - } + ServiceStatus::NotRunning => Check::critical("daemon running", "system", "not loaded") + .with_remedy("meridian restart"), // Not "not running" — we have not looked. See ServiceStatus's docs. ServiceStatus::Unknown => Check::info( "daemon running", diff --git a/src/lib.rs b/src/lib.rs index 892432bbb..d6ac04626 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,6 +30,7 @@ pub mod observability; pub mod plan_tasks; pub mod platform; pub mod pm_worklog; +pub mod restart; pub mod telemetry_spool; pub mod uninstall; pub mod worklog_pipeline; diff --git a/src/main.rs b/src/main.rs index 9199876a3..2a6558eea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1040,6 +1040,30 @@ async fn main() -> Result<()> { // bash `meridian logs` (which tailed launchd-redirected stdout/stderr // text) now that the OTel spool is the sole log/trace sink — see // observability.rs's module doc. No daemon init needed. + // `meridian restart` - restart the managed daemon. This subcommand did not + // exist while FIVE places in this same binary told users to run it, + // including the Jira/Trello OAuth handlers' success output and + // `doctor`'s etl-freshness remedy. It works on a source install only + // because that path symlinks `meridian` at `scripts/meridian-cli.sh`, + // whose bash `cmd_restart` has always existed; a DMG install runs this + // binary directly and got `unknown subcommand "restart"`. + if std::env::args().nth(1).as_deref() == Some("restart") { + let outcome = meridian::restart::run(); + let text = meridian::restart::describe(&outcome); + match outcome { + meridian::restart::Outcome::Restarted => { + println!("{text}"); + return Ok(()); + } + // Exit non-zero so a script (or the tray) can tell "restarted" from + // "there was nothing to restart" without parsing the message. + _ => { + eprintln!("{text}"); + std::process::exit(1); + } + } + } + if std::env::args().nth(1).as_deref() == Some("logs") { let args: Vec = std::env::args().collect(); meridian::telemetry_spool::render::run(&args).await; diff --git a/src/restart.rs b/src/restart.rs new file mode 100644 index 000000000..dc858ba9c --- /dev/null +++ b/src/restart.rs @@ -0,0 +1,250 @@ +//ambient dev tool that watches what you do and updates your PM tickets automatically, boosting developer productivity +//! `meridian restart` - stop and restart the background daemon. +//! +//! # Why this had to exist +//! +//! Five places in this binary already told users to run `meridian restart`, +//! and on a packaged install the command did not exist: +//! +//! - `health::daemon`'s `etl freshness` remedy (the one a production user hit +//! on 2026-08-27, staring at a stalled ETL while `doctor` recommended a +//! command that answered `unknown subcommand "restart"`) +//! - `main.rs`'s Jira and Trello OAuth handlers, printed on the **success** +//! path: "Tokens saved ... run `meridian restart` to pick them up" +//! - `health::diagnose`'s expired-token remedy +//! - `health::capture`'s persistent-failure remedy +//! +//! It worked for whoever wrote it, which is exactly why it survived: the +//! **source** install symlinks `~/.local/bin/meridian` at +//! `scripts/meridian-cli.sh`, whose bash `cmd_restart` has always existed. A +//! **DMG** install has no wrapper - `~/.meridian/bin/meridian` is this native +//! binary - so the same advice was a dead end for every packaged user. +//! +//! The OAuth case is the damaging one and had nothing to do with `doctor`: a +//! user connects Jira, is told to run a command that does not exist, doesn't +//! run it, and their tracker integration silently never picks up the tokens +//! until something else happens to restart the daemon. +//! +//! # Why `kickstart -k` and not `bootout` + `bootstrap` +//! +//! `-k` is precisely what kills a running instance, and here that is what was +//! asked for - unlike the tray's daemon watchdog, where `-k` on a daemon that +//! was merely slow to answer an 800 ms probe corrupted `meridian.db` on every +//! macOS install for days (see `tray/src-tauri/src/poll/watchdog.rs`, which +//! now drops `-k` for exactly that reason). +//! Two things make it correct in this module and not there: the user typed the +//! command, and this runs in a **separate short-lived process**, so it is not +//! the daemon signalling itself. +//! +//! `bootout` + `bootstrap` is deliberately NOT used. It is heavier, and the +//! `launchctl disable` that tends to travel with it has already wedged a plist +//! in this repo badly enough that `bootstrap` returned EIO and the agent had to +//! be reinstalled by hand - see `crate::db::repair::marker`'s module docs. +//! Recovery tooling must not be able to brick the thing it is recovering. +//! +//! # Who calls this +//! `main.rs`'s subcommand dispatch, and every remedy string listed above. + +/// The daemon's launchd label on macOS. Must match the plist that +/// `tray/src-tauri/src/backend_install.rs` renders. +#[cfg(target_os = "macos")] +const DAEMON_LABEL: &str = "com.meridiona.daemon"; + +/// The daemon's Task Scheduler name on Windows. Must match +/// `backend_install::WINDOWS_TASK_NAME`. +#[cfg(target_os = "windows")] +const WINDOWS_TASK_NAME: &str = "Meridian Daemon"; + +/// What `meridian restart` did. +#[derive(Debug, PartialEq, Eq)] +pub enum Outcome { + /// The service manager was asked to restart the daemon. + Restarted, + /// No service is registered for this user - a source/dev checkout, where + /// the daemon is run by hand or by `cargo watch`. Reported rather than + /// silently "succeeding", because a bare exit 0 here would look like the + /// restart happened. + NotRegistered, + /// The service manager refused. + Failed(String), +} + +/// Human-readable result, ready to print. Separated from [`run`] so the +/// wording is testable without a service manager. +pub fn describe(outcome: &Outcome) -> String { + match outcome { + Outcome::Restarted => "Meridian daemon restarted.".to_string(), + Outcome::NotRegistered => { + // Plain hyphens: user-facing text (see the repo's hard rule). + "No Meridian daemon service is registered for your user.\n\ + This is expected on a source checkout - start it the way you \ + normally do (for example 'cargo run --bin meridian'), or install \ + the app to get a managed daemon." + .to_string() + } + Outcome::Failed(why) => format!("Could not restart the Meridian daemon: {why}"), + } +} + +/// Restart the managed daemon for the current user. +pub fn run() -> Outcome { + #[cfg(target_os = "macos")] + { + let target = format!("gui/{}/{}", uid_str(), DAEMON_LABEL); + // Ask first, so a source checkout gets an explanation instead of + // launchctl's "Could not find service" spelled as a raw error. + if !service_is_loaded(&target) { + return Outcome::NotRegistered; + } + match std::process::Command::new("launchctl") + .args(["kickstart", "-k", &target]) + .output() + { + Ok(o) if o.status.success() => Outcome::Restarted, + Ok(o) => Outcome::Failed(format!( + "launchctl kickstart exited {}: {}", + o.status, + String::from_utf8_lossy(&o.stderr).trim() + )), + Err(e) => Outcome::Failed(format!("could not run launchctl: {e}")), + } + } + #[cfg(target_os = "windows")] + { + // No atomic restart verb in Task Scheduler: end it, then run it. `/End` + // failing is not fatal - the task may simply not have been running - + // so only `/Run` decides the outcome. + let _ = std::process::Command::new("schtasks") + .args(["/End", "/TN", WINDOWS_TASK_NAME]) + .output(); + match std::process::Command::new("schtasks") + .args(["/Run", "/TN", WINDOWS_TASK_NAME]) + .output() + { + Ok(o) if o.status.success() => Outcome::Restarted, + // schtasks reports a missing task on stderr with a non-zero exit; + // that is the Windows shape of "no service registered". + Ok(o) => { + let err = String::from_utf8_lossy(&o.stderr).trim().to_string(); + if err.contains("cannot find the file specified") || err.contains("does not exist") + { + Outcome::NotRegistered + } else { + Outcome::Failed(format!("schtasks /Run exited {}: {err}", o.status)) + } + } + Err(e) => Outcome::Failed(format!("could not run schtasks: {e}")), + } + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + Outcome::NotRegistered + } +} + +/// Whether launchd has the label loaded for this user. +/// +/// `launchctl print` exits non-zero for a label that is not loaded at all, +/// which is the source/dev case; it exits 0 both for "loaded and running" and +/// "loaded but stopped", and `kickstart` is correct for either. +#[cfg(target_os = "macos")] +fn service_is_loaded(target: &str) -> bool { + std::process::Command::new("launchctl") + .args(["print", target]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// The current user's uid as launchd wants it in a `gui//