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 && }
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
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
>> {
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