Skip to content

Reduce idle work and improve resource diagnostics with native telemetry#2679

Open
juliusmarminge wants to merge 6 commits into
mainfrom
t3code/a55b49df
Open

Reduce idle work and improve resource diagnostics with native telemetry#2679
juliusmarminge wants to merge 6 commits into
mainfrom
t3code/a55b49df

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented May 13, 2026

Copy link
Copy Markdown
Member

Intent

Reduce T3 Code's steady-state resource usage and make CPU, memory, power-state, and I/O regressions observable enough to debug.

Before this PR:

  • provider status refreshes, VCS remote refreshes, and other background work followed fixed schedules even when nobody needed the result;
  • process diagnostics repeatedly spawned ps or PowerShell, adding process churn while still missing short-lived work between samples;
  • macOS power state came from recurring shell probes;
  • the diagnostics page could show sampled CPU and memory, but had no process I/O counters, collector health, Electron overhead, or application-level write attribution.

This PR replaces those paths with a demand-aware background policy plus a persistent, cross-platform telemetry pipeline.

Runtime architecture

Electron main
  ├─ powerMonitor events and state
  ├─ app.getAppMetrics()
  └─ NDJSON over inherited fd 4
                         ┐
                         ▼
Node server ── NDJSON stdio ── persistent Rust resource monitor
     │
     ├─ ResourceTelemetry Effect service
     ├─ bounded in-memory history and I/O attribution
     ├─ HostPowerMonitor → BackgroundPolicy
     └─ typed RPC/subscription → diagnostics UI

The native monitor is intentionally a standalone executable, not an N-API or FFI module. One persistent child process gives the server a supervised crash boundary, avoids Node/Electron ABI coupling, and eliminates recurring process-table subprocesses. The same protocol and binary layout work for desktop and the published CLI.

What changed

Demand-aware background work

  • Adds an Effect BackgroundPolicy service that combines:
    • active client leases and requested scopes;
    • visibility, focus, recent interaction, app state, battery, and low-power hints;
    • host lock, suspend, battery, low-power, and thermal constraints;
    • balanced, performance, and battery-saver profiles with interval overrides.
  • Clients retain scopes for the data they currently need, such as a repository's VCS status or a provider instance's status.
  • Provider refreshes and VCS remote polling consult the policy instead of blindly running on fixed cadences.
  • Settings and reporting flows expose the policy without coupling individual background services to renderer state.

Native and Electron telemetry

  • Adds native/resource-monitor, a Rust sidecar backed by OS APIs through sysinfo.
  • Samples the server, all descendants, provider-spawned grandchildren such as node, tsgo, language servers and shells, Electron roots, Electron descendants, and the monitor itself.
  • Reports process identity (pid, startTimeMs), parent/child relationships, command line, CPU, cumulative CPU time, resident/virtual memory, cumulative I/O counters, and collection cost.
  • Disables Linux task/thread enumeration and avoids repeatedly reloading command lines.
  • Uses Electron powerMonitor and app.getAppMetrics() for battery/AC state, lock/idle, suspend/resume, thermal state, CPU speed limits, Electron CPU/memory, and idle wakeups.
  • Sends Electron-main data directly to the server over fd 4. It does not route telemetry through the renderer WebSocket.

Diagnostics, history, and safety

  • Adds a schema-backed ResourceTelemetry service that merges native and Electron samples, computes rates from cumulative counters, classifies process groups, and publishes live snapshots.
  • Adds bounded, in-memory history for CPU, memory, I/O, process lifecycle, and collector health. Telemetry is not persisted to disk.
  • Expands diagnostics with:
    • backend, Electron, monitor, and total resource groups;
    • live process tree and process lifecycle counters;
    • CPU, memory, and I/O history;
    • power and thermal state;
    • collector status, restart count, scan duration, and explicit retry;
    • logical write attribution for provider event logs and local trace output.
  • Reuses this service for the legacy process diagnostics RPCs, removing the old ps/PowerShell implementations entirely.
  • Validates process signals against both PID and start time and only permits signalable backend descendants; Electron and monitor processes remain visible but protected.

Reliability and packaging

  • Models monitor resolution, protocol, transport, and refresh failures as schema-backed tagged error unions with descriptive messages.
  • Supervises the sidecar with version negotiation, bounded restart backoff, a failure circuit, health transitions, and manual retry.
  • Handles sidecar sequence resets across restarts and preserves native rates during Electron-only updates.
  • Builds and packages platform executables in desktop resources and CLI releases for supported macOS, Linux, and Windows targets.
  • Adds CI coverage for Rust formatting/tests and release wiring for per-platform artifacts.

Metric semantics and limits

  • Sampling is once per second, not syscall or process-lifecycle tracing. A process that starts and exits entirely between samples can still be missed.
  • Unix-like and Windows process I/O counters have different OS semantics, and filesystem caches mean they are not guaranteed to equal physical SSD traffic. The UI therefore labels them as I/O reads/writes, not disk reads/writes.
  • Application attribution reports known logical writes from instrumented T3 Code components; it complements rather than replaces process-level OS counters.
  • Headless/web servers still receive native process telemetry. Electron-specific power fields degrade to unknown instead of falling back to shell probes.
  • Collector failures degrade telemetry without taking down the server.

Validation

  • vp check
  • vp run typecheck
  • 173 tests across every modified test file
  • 11 VCS/background-policy merge-integration tests
  • cargo fmt --manifest-path native/resource-monitor/Cargo.toml -- --check
  • cargo test --locked --manifest-path native/resource-monitor/Cargo.toml (6 tests)
  • cargo clippy --manifest-path native/resource-monitor/Cargo.toml -- -D warnings
  • vp run build:desktop
  • Live sidecar smoke test against the local process table, including full command-line and I/O output

Note

High Risk
Large cross-cutting change (desktop IPC, new native binary packaging, diagnostics/signal semantics, and background scheduling) with behavior changes when clients omit startTimeMs or when the sidecar is missing on a platform.

Overview
Adds a Rust t3-resource-monitor sidecar and wires it through CI/release (per-platform builds, CLI bundle, desktop resourceMonitorPath on backend bootstrap). Process diagnostics and resource history no longer spawn ps/PowerShell; they read from a new ResourceTelemetry pipeline that merges native snapshots with Electron main metrics sent over inherited fds 4/5 (DesktopTelemetryPublisher → server DesktopTelemetryReceiver).

Introduces BackgroundPolicy and HostPowerMonitor so scoped background work (e.g. provider status, VCS) can follow client leases, host power/thermal/battery, and activity profiles instead of fixed 5‑minute provider refresh intervals (those refreshInterval knobs are removed from drivers).

Desktop backend child logging is reworked: output is buffered (1 MiB / 256 chunks) and only flushed to disk on failure; graceful stop discards the session. The backend manager drains stdout/stderr before reporting unexpected exits and routes NDJSON control messages on fd5 for diagnostics demand.

Process signal RPCs now require (pid, startTimeMs) identity validation and block Electron/monitor targets. Trace batch default moves 200 ms → 1 s; trace flushes record logical write attribution via ResourceAttribution.

Reviewed by Cursor Bugbot for commit 2b59d67. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add native resource monitor and BackgroundPolicy to reduce idle work and improve diagnostics

  • Introduces a new Rust sidecar binary (native/resource-monitor/) that streams process snapshots and history via a JSON protocol, replacing direct OS command spawning (ps/powershell) in ProcessDiagnostics and ProcessResourceMonitor.
  • Adds a ResourceTelemetry service that merges native and desktop (Electron) telemetry into unified snapshots, exposed over WebSocket RPCs and a new ResourceTelemetryDiagnostics UI.
  • Adds a BackgroundPolicy service that gates periodic work — VCS remote refreshes and provider health checks — based on active client leases, host power state, and the user's selected background activity profile.
  • Introduces a background activity settings model (balanced/performance/battery-saver profiles with custom overrides) wired into the General Settings and Provider Settings panels and persisted under settings.backgroundActivity.
  • Adds DesktopTelemetryPublisher in the Electron main process to stream Electron app metrics and power state to the backend over dedicated FDs (4 and 5).
  • Backend child output logging is now buffered in memory and only flushed to disk when persistFailure is called, reducing log noise on clean exits.
  • Risk: ProcessDiagnostics.signal now requires a matching (pid, startTimeMs) identity and rejects non-backend process categories — existing callers that do not supply startTimeMs will receive validation failures.

Macroscope summarized 2b59d67.

@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 5334b2b9-e447-452a-92fc-9151fc1f8a91

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/a55b49df

Comment @coderabbitai help to get the list of available commands and usage tips.

@juliusmarminge juliusmarminge marked this pull request as draft May 13, 2026 22:23
@juliusmarminge juliusmarminge marked this pull request as draft May 13, 2026 22:23
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels May 13, 2026
Comment on lines +9 to +30
export function createServerSettingsWriteQueue(input: {
readonly updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>;
readonly applySettings: (settings: ServerSettings) => void;
readonly onError: (error: unknown) => void;
}): ServerSettingsWriteQueue {
let queue: Promise<void> = Promise.resolve();

return {
enqueue: (patch) => {
queue = queue
.catch(() => undefined)
.then(async () => {
const settings = await input.updateSettings(patch);
input.applySettings(settings);
})
.catch(input.onError);
},
reset: () => {
queue = Promise.resolve();
},
drain: () => queue,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low hooks/serverSettingsWriteQueue.ts:9

reset() breaks serialization by replacing queue with Promise.resolve() while prior operations are still in-flight. The old chain continues running and will invoke applySettings and onError, but new enqueue() calls start immediately from the resolved promise, so their operations run concurrently with the old ones. This allows applySettings callbacks to fire out-of-order, with stale settings potentially overwriting newer ones.

  return {
    enqueue: (patch) => {
      queue = queue
        .catch(() => undefined)
        .then(async () => {
          const settings = await input.updateSettings(patch);
          input.applySettings(settings);
        })
        .catch(input.onError);
    },
-    reset: () => {
-      queue = Promise.resolve();
-    },
+    reset: () => {
+      const currentQueue = queue;
+      queue = queue.then(() => {});
+      return currentQueue;
+    },
    drain: () => queue,
  };
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/hooks/serverSettingsWriteQueue.ts around lines 9-30:

`reset()` breaks serialization by replacing `queue` with `Promise.resolve()` while prior operations are still in-flight. The old chain continues running and will invoke `applySettings` and `onError`, but new `enqueue()` calls start immediately from the resolved promise, so their operations run concurrently with the old ones. This allows `applySettings` callbacks to fire out-of-order, with stale settings potentially overwriting newer ones.

Evidence trail:
apps/web/src/hooks/serverSettingsWriteQueue.ts lines 16-32 (reset() implementation at lines 28-30, enqueue() at lines 18-25); apps/web/src/hooks/useSettings.ts line 237-244 (__resetClientSettingsPersistenceForTests calls serverSettingsWriteQueue.reset()); apps/web/src/localApi.ts lines 194-204 (__resetLocalApiForTests calls __resetClientSettingsPersistenceForTests); git_grep for 'serverSettingsWriteQueue\.reset' shows only one caller in test cleanup code.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Legacy patch fields silently override explicit backgroundActivity patch
    • Added a guard to skip computing backgroundActivityPatch when the explicit backgroundActivity field is present in the patch, ensuring new-style patches take precedence over legacy fields.
  • ✅ Fixed: Duplicated utility functions across settings panel files
    • Extracted BackgroundActivityOverridePatch, durationToSeconds, normalizeIntervalSeconds, backgroundActivityOverrideSettings, and BackgroundPolicyTooltip into a shared backgroundActivityUtils.tsx module imported by both SettingsPanels.tsx and SourceControlSettings.tsx.

Create PR

Or push these changes by commenting:

@cursor push 6d184f0660
Preview (6d184f0660)
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -1,7 +1,6 @@
 import {
   ArchiveIcon,
   ArchiveX,
-  InfoIcon,
   LoaderIcon,
   PlusIcon,
   RefreshCwIcon,
@@ -106,6 +105,12 @@
   SettingsSection,
   useRelativeTimeTick,
 } from "./settingsLayout";
+import {
+  BackgroundPolicyTooltip as PolicyTooltip,
+  backgroundActivityOverrideSettings,
+  durationToSeconds,
+  normalizeIntervalSeconds,
+} from "./backgroundActivityUtils";
 import { ProjectFavicon } from "../ProjectFavicon";
 import { useServerObservability, useServerProviders } from "../../rpc/serverState";
 
@@ -137,11 +142,6 @@
 };
 
 type BackgroundActivityProfileOption = BackgroundActivityProfile | "advanced";
-type BackgroundActivityOverridePatch = Partial<{
-  [K in keyof BackgroundActivitySettings["overrides"]]:
-    | BackgroundActivitySettings["overrides"][K]
-    | undefined;
-}>;
 
 const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record<BackgroundActivityProfileOption, string> = {
   ...BACKGROUND_ACTIVITY_PROFILE_LABELS,
@@ -174,17 +174,6 @@
   { key: "pauseWhenOnBattery", label: "Pause on battery" },
 ];
 
-function durationToSeconds(duration: Duration.Duration): number {
-  return Math.round(Duration.toMillis(duration) / 1_000);
-}
-
-function normalizeIntervalSeconds(value: number | null): number {
-  if (value === null || !Number.isFinite(value)) {
-    return 0;
-  }
-  return Math.max(0, Math.round(value));
-}
-
 function resolveBackgroundActivityProfileOption(settings: {
   readonly backgroundActivity: BackgroundActivitySettings;
 }): BackgroundActivityProfileOption {
@@ -209,50 +198,6 @@
   };
 }
 
-function backgroundActivityOverrideSettings(
-  current: BackgroundActivitySettings,
-  overrides: BackgroundActivityOverridePatch,
-) {
-  const nextOverrides: BackgroundActivityOverridePatch = {
-    ...current.overrides,
-    ...overrides,
-  };
-  for (const [key, value] of Object.entries(nextOverrides)) {
-    if (value === undefined) {
-      delete nextOverrides[key as keyof typeof nextOverrides];
-    }
-  }
-  return {
-    backgroundActivity: {
-      schemaVersion: 1 as const,
-      profile: "custom" as const,
-      baseProfile: getBackgroundActivityBaseProfile(current),
-      overrides: nextOverrides as BackgroundActivitySettings["overrides"],
-    },
-  };
-}
-
-function PolicyTooltip({ children }: { readonly children: string }) {
-  return (
-    <Tooltip>
-      <TooltipTrigger
-        render={
-          <button
-            type="button"
-            className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
-            aria-label="Background policy details"
-          >
-            <InfoIcon className="size-3.5" />
-          </button>
-        }
-      />
-      <TooltipPopup side="top" className="max-w-72">
-        {children}
-      </TooltipPopup>
-    </Tooltip>
-  );
-}
-
 function withoutProviderInstanceKey<V>(
   record: Readonly<Record<ProviderInstanceId, V>> | undefined,
   key: ProviderInstanceId,

diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx
--- a/apps/web/src/components/settings/SourceControlSettings.tsx
+++ b/apps/web/src/components/settings/SourceControlSettings.tsx
@@ -1,9 +1,8 @@
-import { ChevronDownIcon, GitPullRequestIcon, InfoIcon, RefreshCwIcon } from "lucide-react";
+import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react";
 import * as Duration from "effect/Duration";
 import * as Option from "effect/Option";
 import { useState, type ReactNode } from "react";
 import type {
-  BackgroundActivitySettings,
   SourceControlProviderKind,
   SourceControlDiscoveryResult,
   SourceControlProviderAuth,
@@ -55,6 +54,12 @@
 } from "../Icons";
 import { RedactedSensitiveText } from "./RedactedSensitiveText";
 import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout";
+import {
+  BackgroundPolicyTooltip,
+  backgroundActivityOverrideSettings,
+  durationToSeconds,
+  normalizeIntervalSeconds as normalizeFetchIntervalSeconds,
+} from "./backgroundActivityUtils";
 
 const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
   versionControlSystems: [],
@@ -75,44 +80,7 @@
 
 const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const;
 const GIT_FETCH_INTERVAL_STEP_SECONDS = 5;
-type BackgroundActivityOverridePatch = Partial<{
-  [K in keyof BackgroundActivitySettings["overrides"]]:
-    | BackgroundActivitySettings["overrides"][K]
-    | undefined;
-}>;
 
-function durationToSeconds(duration: Duration.Duration): number {
-  return Math.round(Duration.toMillis(duration) / 1_000);
-}
-
-function normalizeFetchIntervalSeconds(value: number | null): number {
-  if (value === null || !Number.isFinite(value)) {
-    return 0;
-  }
-  return Math.max(0, Math.round(value));
-}
-
-function BackgroundPolicyTooltip({ children }: { readonly children: string }) {
-  return (
-    <Tooltip>
-      <TooltipTrigger
-        render={
-          <button
-            type="button"
-            className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
-            aria-label="Background policy details"
-          >
-            <InfoIcon className="size-3.5" />
-          </button>
-        }
-      />
-      <TooltipPopup side="top" className="max-w-72">
-        {children}
-      </TooltipPopup>
-    </Tooltip>
-  );
-}
-
 function optionLabel(value: Option.Option<string>): string | null {
   return Option.getOrNull(value);
 }
@@ -335,28 +303,6 @@
   );
   const canResetFetchInterval =
     automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds;
-  const backgroundActivityOverrideSettings = (
-    current: BackgroundActivitySettings,
-    overrides: BackgroundActivityOverridePatch,
-  ) => {
-    const nextOverrides: BackgroundActivityOverridePatch = {
-      ...current.overrides,
-      ...overrides,
-    };
-    for (const [key, value] of Object.entries(nextOverrides)) {
-      if (value === undefined) {
-        delete nextOverrides[key as keyof typeof nextOverrides];
-      }
-    }
-    return {
-      backgroundActivity: {
-        schemaVersion: 1 as const,
-        profile: "custom" as const,
-        baseProfile: getBackgroundActivityBaseProfile(current),
-        overrides: nextOverrides as BackgroundActivitySettings["overrides"],
-      },
-    };
-  };
 
   return (
     <div className="grid gap-3">

diff --git a/apps/web/src/components/settings/backgroundActivityUtils.tsx b/apps/web/src/components/settings/backgroundActivityUtils.tsx
new file mode 100644
--- /dev/null
+++ b/apps/web/src/components/settings/backgroundActivityUtils.tsx
@@ -1,0 +1,67 @@
+import { InfoIcon } from "lucide-react";
+import * as Duration from "effect/Duration";
+import type { BackgroundActivitySettings } from "@t3tools/contracts";
+import { getBackgroundActivityBaseProfile } from "@t3tools/shared/backgroundActivitySettings";
+
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+
+export type BackgroundActivityOverridePatch = Partial<{
+  [K in keyof BackgroundActivitySettings["overrides"]]:
+    | BackgroundActivitySettings["overrides"][K]
+    | undefined;
+}>;
+
+export function durationToSeconds(duration: Duration.Duration): number {
+  return Math.round(Duration.toMillis(duration) / 1_000);
+}
+
+export function normalizeIntervalSeconds(value: number | null): number {
+  if (value === null || !Number.isFinite(value)) {
+    return 0;
+  }
+  return Math.max(0, Math.round(value));
+}
+
+export function backgroundActivityOverrideSettings(
+  current: BackgroundActivitySettings,
+  overrides: BackgroundActivityOverridePatch,
+) {
+  const nextOverrides: BackgroundActivityOverridePatch = {
+    ...current.overrides,
+    ...overrides,
+  };
+  for (const [key, value] of Object.entries(nextOverrides)) {
+    if (value === undefined) {
+      delete nextOverrides[key as keyof typeof nextOverrides];
+    }
+  }
+  return {
+    backgroundActivity: {
+      schemaVersion: 1 as const,
+      profile: "custom" as const,
+      baseProfile: getBackgroundActivityBaseProfile(current),
+      overrides: nextOverrides as BackgroundActivitySettings["overrides"],
+    },
+  };
+}
+
+export function BackgroundPolicyTooltip({ children }: { readonly children: string }) {
+  return (
+    <Tooltip>
+      <TooltipTrigger
+        render={
+          <button
+            type="button"
+            className="inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
+            aria-label="Background policy details"
+          >
+            <InfoIcon className="size-3.5" />
+          </button>
+        }
+      />
+      <TooltipPopup side="top" className="max-w-72">
+        {children}
+      </TooltipPopup>
+    </Tooltip>
+  );
+}

diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts
--- a/packages/shared/src/serverSettings.ts
+++ b/packages/shared/src/serverSettings.ts
@@ -89,26 +89,28 @@
     ...patchForMerge
   } = patch;
   const backgroundActivityPatch =
-    backgroundActivityProfile !== undefined
-      ? {
-          schemaVersion: 1 as const,
-          profile: backgroundActivityProfile,
-          overrides: {},
-        }
-      : automaticGitFetchInterval !== undefined || providerHealthRefreshInterval !== undefined
+    backgroundActivity !== undefined
+      ? undefined
+      : backgroundActivityProfile !== undefined
         ? {
             schemaVersion: 1 as const,
-            profile: "custom" as const,
-            baseProfile: getBackgroundActivityBaseProfile(current.backgroundActivity),
-            overrides: {
-              ...current.backgroundActivity.overrides,
-              ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}),
-              ...(providerHealthRefreshInterval !== undefined
-                ? { providerHealthRefreshInterval }
-                : {}),
-            },
+            profile: backgroundActivityProfile,
+            overrides: {},
           }
-        : undefined;
+        : automaticGitFetchInterval !== undefined || providerHealthRefreshInterval !== undefined
+          ? {
+              schemaVersion: 1 as const,
+              profile: "custom" as const,
+              baseProfile: getBackgroundActivityBaseProfile(current.backgroundActivity),
+              overrides: {
+                ...current.backgroundActivity.overrides,
+                ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}),
+                ...(providerHealthRefreshInterval !== undefined
+                  ? { providerHealthRefreshInterval }
+                  : {}),
+              },
+            }
+          : undefined;
   const next = deepMerge(current, patchForMerge);
   const nextWithReplacementsBase = {
     ...next,

You can send follow-ups to the cloud agent here.

Comment thread packages/shared/src/serverSettings.ts
Comment thread apps/web/src/components/settings/SourceControlSettings.tsx
@macroscopeapp

macroscopeapp Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

2 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

You can customize Macroscope's approvability policy. Learn more.

- Track client leases, host power, and background work eligibility
- Route provider snapshot refreshes through shared background policy
- Add tests for the new policy and related server settings wiring

Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge juliusmarminge marked this pull request as ready for review May 20, 2026 08:44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

function normalizeIntervalSeconds(value: number | null): number {
if (value === null || !Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.round(value));
}

When a user clears the NumberField for host power monitor intervals, onValueChange receives null. normalizeIntervalSeconds(null) returns 0, bypassing the min={5} constraint and saving Duration.seconds(0) to settings. This violates the minimum interval requirement, which could cause excessive background polling.

Consider enforcing the minimum in normalizeIntervalSeconds or handling the null case explicitly to ensure the saved value respects the min constraint.

function normalizeIntervalSeconds(value: number | null): number {
   if (value === null || !Number.isFinite(value)) {
-    return 0;
+    return NaN;
   }
-  return Math.max(0, Math.round(value));
+  return Math.max(5, Math.round(value));
 }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/components/settings/SettingsPanels.tsx around lines 181-186:

When a user clears the `NumberField` for host power monitor intervals, `onValueChange` receives `null`. `normalizeIntervalSeconds(null)` returns `0`, bypassing the `min={5}` constraint and saving `Duration.seconds(0)` to settings. This violates the minimum interval requirement, which could cause excessive background polling.

Consider enforcing the minimum in `normalizeIntervalSeconds` or handling the `null` case explicitly to ensure the saved value respects the `min` constraint.

Evidence trail:
SettingsPanels.tsx lines 181-186: normalizeIntervalSeconds returns 0 for null. Lines 778-801: Host power monitor active interval NumberField with min={5} and onValueChange at line 784 that saves Duration.seconds(normalizeIntervalSeconds(value)). Lines 804-835: Idle host power monitor NumberField also min={5}. Lines 212-233: backgroundActivityOverrideSettings performs no validation on the duration value. apps/web/package.json line 16: @base-ui/react ^1.4.1. apps/web/src/components/ui/number-field.tsx: NumberField wraps @base-ui/react NumberFieldPrimitive.Root with props passthrough.

Co-authored-by: codex <codex@users.noreply.github.com>

# Conflicts:
#	apps/server/src/provider/Drivers/CodexDriver.ts
#	apps/server/src/provider/Drivers/CursorDriver.ts
#	apps/server/src/provider/Drivers/OpenCodeDriver.ts
#	apps/server/src/server.ts
#	apps/server/src/ws.ts
#	apps/web/src/environments/runtime/service.ts
#	apps/web/src/lib/gitStatusState.ts
#	packages/client-runtime/src/wsRpcClient.ts
#	packages/contracts/src/rpc.ts
#	packages/shared/src/serverSettings.test.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: VCS scope cwd mismatch
    • Added a scopeCwd parameter to makeRemoteRefreshLoop and retainRemotePoller that passes the raw (un-normalized) input.cwd to shouldRunScopeWork, ensuring scope keys match the client's lease scopes.
  • ✅ Fixed: Disconnect never clears leases
    • Added removeSession method to BackgroundPolicy that removes leases by sessionId, and wired it into the WebSocket disconnect handler's acquireUseRelease release function.
  • ✅ Fixed: Advanced dialog clears overrides
    • Changed the Advanced dialog's shared policy Select to update baseProfile while keeping profile as 'custom' and preserving existing overrides, instead of calling backgroundActivityProfileSettings which resets everything.

Create PR

Or push these changes by commenting:

@cursor push 44ee7a6c3a
Preview (44ee7a6c3a)
diff --git a/apps/server/src/background/BackgroundPolicy.ts b/apps/server/src/background/BackgroundPolicy.ts
--- a/apps/server/src/background/BackgroundPolicy.ts
+++ b/apps/server/src/background/BackgroundPolicy.ts
@@ -30,6 +30,7 @@
     input: ClientActivityReportInput,
   ) => Effect.Effect<void>;
   readonly removeRpcClient: (rpcClientId: RpcClientId) => Effect.Effect<void>;
+  readonly removeSession: (sessionId: AuthSessionId) => Effect.Effect<void>;
   readonly reportHostPowerState: (snapshot: HostPowerSnapshot) => Effect.Effect<void>;
   readonly snapshot: Effect.Effect<BackgroundPolicySnapshot>;
   readonly streamChanges: Stream.Stream<BackgroundPolicySnapshot>;
@@ -218,6 +219,17 @@
       return next;
     }).pipe(Effect.andThen(publishSnapshot), Effect.asVoid);
 
+  const removeSession: BackgroundPolicyShape["removeSession"] = (sessionId) =>
+    Ref.update(leasesRef, (leases) => {
+      const next = new Map(leases);
+      for (const [key, lease] of next) {
+        if (lease.sessionId === sessionId) {
+          next.delete(key);
+        }
+      }
+      return next;
+    }).pipe(Effect.andThen(publishSnapshot), Effect.asVoid);
+
   const hasDemand: BackgroundPolicyShape["hasDemand"] = (scope) =>
     Effect.map(snapshot, (current) => current.activeScopeKeys.includes(scopeKey(scope)));
 
@@ -264,6 +276,7 @@
   return BackgroundPolicy.of({
     reportClientActivity,
     removeRpcClient,
+    removeSession,
     reportHostPowerState: hostPowerMonitor.report,
     snapshot,
     streamChanges: Stream.fromPubSub(changes),

diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts
--- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts
+++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts
@@ -64,6 +64,7 @@
 const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({
   reportClientActivity: () => Effect.void,
   removeRpcClient: () => Effect.void,
+  removeSession: () => Effect.void,
   reportHostPowerState: () => Effect.void,
   snapshot: Effect.succeed({
     hostPower: {

diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts
--- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts
+++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts
@@ -78,6 +78,7 @@
 const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({
   reportClientActivity: () => Effect.void,
   removeRpcClient: () => Effect.void,
+  removeSession: () => Effect.void,
   reportHostPowerState: () => Effect.void,
   snapshot: Effect.succeed({
     hostPower: {

diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts
--- a/apps/server/src/provider/makeManagedServerProvider.test.ts
+++ b/apps/server/src/provider/makeManagedServerProvider.test.ts
@@ -97,6 +97,7 @@
   return Layer.mock(BackgroundPolicy.BackgroundPolicy)({
     reportClientActivity: () => Effect.void,
     removeRpcClient: () => Effect.void,
+    removeSession: () => Effect.void,
     reportHostPowerState: () => Effect.void,
     snapshot: Effect.succeed({
       hostPower: {

diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -777,6 +777,7 @@
         Layer.mock(BackgroundPolicy.BackgroundPolicy)({
           reportClientActivity: () => Effect.void,
           removeRpcClient: () => Effect.void,
+          removeSession: () => Effect.void,
           reportHostPowerState: () => Effect.void,
           snapshot: Effect.succeed({
             hostPower: {

diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts
--- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts
+++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts
@@ -92,6 +92,7 @@
   return Layer.mock(BackgroundPolicy.BackgroundPolicy)({
     reportClientActivity: () => Effect.void,
     removeRpcClient: () => Effect.void,
+    removeSession: () => Effect.void,
     reportHostPowerState: () => Effect.void,
     snapshot: Effect.succeed({
       hostPower: {

diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts
--- a/apps/server/src/vcs/VcsStatusBroadcaster.ts
+++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts
@@ -258,6 +258,7 @@
 
     const makeRemoteRefreshLoop = (
       cwd: string,
+      scopeCwd: string,
       automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,
       refreshImmediately: boolean,
     ) => {
@@ -274,7 +275,7 @@
 
           const shouldRun = yield* backgroundPolicy.shouldRunScopeWork({
             type: "vcs-status",
-            cwd,
+            cwd: scopeCwd,
           });
           if (!shouldRun) {
             return activeInterval;
@@ -322,6 +323,7 @@
 
     const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* (
       cwd: string,
+      scopeCwd: string,
       automaticRemoteRefreshInterval: Effect.Effect<Duration.Duration, never>,
       refreshImmediately: boolean,
     ) {
@@ -336,7 +338,12 @@
           return Effect.succeed([undefined, nextPollers] as const);
         }
 
-        return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval, refreshImmediately).pipe(
+        return makeRemoteRefreshLoop(
+          cwd,
+          scopeCwd,
+          automaticRemoteRefreshInterval,
+          refreshImmediately,
+        ).pipe(
           Effect.forkIn(broadcasterScope),
           Effect.map((fiber) => {
             const nextPollers = new Map(activePollers);
@@ -388,6 +395,7 @@
           const initialRemote = cachedStatus?.remote?.value ?? null;
           yield* retainRemotePoller(
             cwd,
+            input.cwd,
             options?.automaticRemoteRefreshInterval ??
               Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL),
             cachedStatus?.remote === null || cachedStatus?.remote === undefined,

diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -1669,6 +1669,7 @@
         const request = yield* HttpServerRequest.HttpServerRequest;
         const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
         const sessions = yield* SessionStore.SessionStore;
+        const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
         const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(
           Effect.catchTags({
             ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
@@ -1710,7 +1711,11 @@
         return yield* Effect.acquireUseRelease(
           sessions.markConnected(session.sessionId),
           () => rpcWebSocketHttpEffect,
-          () => sessions.markDisconnected(session.sessionId),
+          () =>
+            Effect.all([
+              sessions.markDisconnected(session.sessionId),
+              backgroundPolicy.removeSession(session.sessionId),
+            ]).pipe(Effect.asVoid),
         );
       }).pipe(
         Effect.catchTags({

diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -680,7 +680,14 @@
                     value === "performance" ||
                     value === "battery-saver"
                   ) {
-                    updateSettings(backgroundActivityProfileSettings(value));
+                    updateSettings({
+                      backgroundActivity: {
+                        schemaVersion: 1 as const,
+                        profile: "custom" as const,
+                        baseProfile: value,
+                        overrides: settings.backgroundActivity.overrides,
+                      },
+                    });
                   }
                 }}
               >

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/vcs/VcsStatusBroadcaster.ts
Comment thread apps/server/src/background/BackgroundPolicy.ts
Comment thread apps/web/src/components/settings/SettingsPanels.tsx
@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

🚀 Expo continuous deployment is ready!

  • Project → t3-code
  • Platforms → android, ios
  • Scheme → t3code-preview
  🤖 Android 🍎 iOS
Fingerprint 052dc86843f08d733f88b4c444db61397f6e5117 56f0ff9f61b9906d203c2fea265e3f9507332117
Build Details Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 052dc86843f08d733f88b4c444db61397f6e5117
App version: 0.1.0
Git commit: 25f9245847d2ae02ace30e4e50c539bd82c38e59
Build Permalink
DetailsDistribution: INTERNAL
Build profile: preview:dev
Runtime version: 56f0ff9f61b9906d203c2fea265e3f9507332117
App version: 0.1.0
Git commit: 25f9245847d2ae02ace30e4e50c539bd82c38e59
Update Details Update Permalink
DetailsBranch: pr-2679
Runtime version: 052dc86843f08d733f88b4c444db61397f6e5117
Git commit: 25f9245847d2ae02ace30e4e50c539bd82c38e59
Update Permalink
DetailsBranch: pr-2679
Runtime version: 56f0ff9f61b9906d203c2fea265e3f9507332117
Git commit: 25f9245847d2ae02ace30e4e50c539bd82c38e59
Update QR

Comment thread apps/server/src/ws.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical

[WS_METHODS.subscribeServerLifecycle, AuthOrchestrationReadScope],

The new RPC handlers serverReportClientActivity, serverReportHostPowerState, serverGetBackgroundPolicy, and subscribeBackgroundPolicy are missing entries in RPC_REQUIRED_SCOPE, so requiredScopeForMethod() throws Error: RPC method X has no declared authorization scope. when any of these methods are invoked.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/ws.ts around line 210:

The new RPC handlers `serverReportClientActivity`, `serverReportHostPowerState`, `serverGetBackgroundPolicy`, and `subscribeBackgroundPolicy` are missing entries in `RPC_REQUIRED_SCOPE`, so `requiredScopeForMethod()` throws `Error: RPC method X has no declared authorization scope.` when any of these methods are invoked.

Evidence trail:
apps/server/src/ws.ts lines 143-212 (RPC_REQUIRED_SCOPE map — none of the four methods listed); apps/server/src/ws.ts lines 321-327 (requiredScopeForMethod throws if method not in map); apps/server/src/ws.ts lines 1112-1131 (serverReportClientActivity, serverReportHostPowerState, serverGetBackgroundPolicy handlers using observeRpcEffect); apps/server/src/ws.ts lines 1650-1658 (subscribeBackgroundPolicy handler using observeRpcStream); apps/server/src/ws.ts lines 333-345 (observeRpcEffect and observeRpcStream call requiredScopeForMethod).

juliusmarminge and others added 2 commits June 17, 2026 13:34
Replace scheduled process shell probes with a persistent native collector, Electron power/process telemetry, Effect services, diagnostics UI, attribution, and release packaging.

Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
@t3dotgg t3dotgg changed the title Add background activity policy and host power monitoring Add native resource telemetry and demand-aware background scheduling Jun 17, 2026
@t3dotgg t3dotgg changed the title Add native resource telemetry and demand-aware background scheduling Reduce idle work and improve resource diagnostics with native telemetry Jun 17, 2026
- Add fd5 control channel for desktop telemetry demand
- Stop gating background work on stale host power
- Update telemetry sampling and receiver tests
if (previous.sampleIntervalMs !== next.sampleIntervalMs) {
yield* publishHealth;
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Power updates ignored when degraded

Medium Severity

setHostPowerState and live-subscriber changes always update in-memory collectionControl, but applyCollectionControl only sends setSampleInterval / setStreaming to the sidecar when client status is exactly healthy. While the monitor is degraded yet still running, host suspend or constraint changes will not pause or slow native sampling until the client returns to healthy or reconnects.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0ebbdad. Configure here.

- Capture child process output in memory during normal runs
- Persist buffered output only on failures and bound retained data
- Increase trace/event batching windows to reduce flush churn

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Output drain timeout drops logs
    • Added Fiber.interrupt for all output fibers after the 250ms drain timeout so that still-running fibers are stopped before persistFailure snapshots and clears the session buffer, preventing late chunks from being lost.

Create PR

Or push these changes by commenting:

@cursor push 3c6ed7cfa6
Preview (3c6ed7cfa6)
diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts
--- a/apps/desktop/src/backend/DesktopBackendManager.ts
+++ b/apps/desktop/src/backend/DesktopBackendManager.ts
@@ -330,6 +330,10 @@
     concurrency: "unbounded",
     discard: true,
   }).pipe(Effect.timeout(DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT), Effect.ignore);
+  yield* Effect.forEach(outputFibers, Fiber.interrupt, {
+    concurrency: "unbounded",
+    discard: true,
+  }).pipe(Effect.ignore);
   return exit;
 });

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 2b59d67. Configure here.

yield* Effect.forEach(outputFibers, Fiber.await, {
concurrency: "unbounded",
discard: true,
}).pipe(Effect.timeout(DEFAULT_BACKEND_OUTPUT_DRAIN_TIMEOUT), Effect.ignore);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Output drain timeout drops logs

Medium Severity

After the backend child exits, runBackendProcess waits at most 250ms for stdout/stderr drain fibers, then finalizeRun calls persistFailure once. If draining or writeOutputChunk takes longer, or fibers still run after the timeout, failure logs can omit trailing output that never gets another persist pass.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2b59d67. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant