diff --git a/AGENTS.md b/AGENTS.md index c4f7d53a..22d5c67e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,8 @@ ## Windows-Specific Notes +- The dedicated provider-daemon lowers its own priority before constructing provider runtimes. Windows normally launched children inherit BELOW_NORMAL_PRIORITY_CLASS; no process-table scan or external PID mutation is needed. Preserve an already lower launcher priority and continue startup if the OS rejects the request. This leaves Electron, the backend, direct backend-hosted providers, and already-running external runtimes unchanged. macOS/Linux use the same Node priority boundary and normal child inheritance. Native inheritance verification must use an explicit synthetic-process smoke, never change the test runner's priority in the default suite. + - The separate scheduled/manual reliability workflow runs the existing Windows native installer/runtime/uninstaller smoke only on disposable GitHub-hosted Windows runners. It does not run on user machines or move process-backed/provider tests into the default suite. macOS uses its isolated DMG/ZIP smoke, and Linux keeps its existing artifact build and pipeline coverage. - Generic attachment originals and metadata are private `0600` files where POSIX permissions apply; provider-readable derivatives are `0400`. On Windows leave derivatives user-writable under Cafe's user-owned data-directory ACL instead of setting the read-only attribute, which would prevent safe cleanup. File names are inert display metadata and storage uses server-minted identifiers; Windows path separators in dropped names must never become server paths. Document extraction children use the current executable with `ELECTRON_RUN_AS_NODE=1` for packaged Electron backends and preserve only required Windows system-directory environment entries, not provider credentials or user-selected Node hooks. macOS/Linux retain the same isolated child behavior and their native permission checks. diff --git a/apps/server/src/cli/providerDaemon.ts b/apps/server/src/cli/providerDaemon.ts index e9dde8e2..e0e01b6e 100644 --- a/apps/server/src/cli/providerDaemon.ts +++ b/apps/server/src/cli/providerDaemon.ts @@ -18,6 +18,7 @@ import { } from "../config.ts"; import { runProviderDaemonServerForever } from "../providerDaemon/ProviderDaemonServer.ts"; import { ProviderDaemonRuntimeLive } from "../providerDaemon/ProviderDaemonRuntime.ts"; +import { lowerProviderDaemonPriority } from "../providerDaemon/ProviderDaemonPriority.ts"; import { PROVIDER_SUPERVISOR_PROTOCOL_VERSION } from "../providerDaemon/ProviderSupervisorProcessManager.ts"; import { ObservabilityLive } from "../observability/Layers/Observability.ts"; import packageJson from "../../package.json" with { type: "json" }; @@ -104,6 +105,11 @@ export const runProviderDaemonCommand = (flags: { readonly bootstrapFd: Option.O }); } + const priority = yield* Effect.sync(() => lowerProviderDaemonPriority()); + if (priority === "unavailable") { + yield* Effect.logWarning("provider.daemon.priority.unavailable"); + } + const baseConfig = yield* resolveProviderDaemonServerConfig({ bootstrap, logLevel, diff --git a/apps/server/src/providerDaemon/ProviderDaemonPriority.test.ts b/apps/server/src/providerDaemon/ProviderDaemonPriority.test.ts new file mode 100644 index 00000000..a0d896b2 --- /dev/null +++ b/apps/server/src/providerDaemon/ProviderDaemonPriority.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; + +import { lowerProviderDaemonPriority, PROVIDER_DAEMON_PRIORITY } from "./ProviderDaemonPriority.ts"; + +describe("provider daemon scheduling", () => { + it("lowers only the current daemon from normal priority", () => { + const setPriority = vi.fn(); + expect(lowerProviderDaemonPriority({ getPriority: () => 0, setPriority })).toBe("lowered"); + expect(setPriority).toHaveBeenCalledExactlyOnceWith(0, PROVIDER_DAEMON_PRIORITY); + }); + + it.each([PROVIDER_DAEMON_PRIORITY, 19])("preserves a launcher priority of %i", (priority) => { + const setPriority = vi.fn(); + expect(lowerProviderDaemonPriority({ getPriority: () => priority, setPriority })).toBe( + "already-lower", + ); + expect(setPriority).not.toHaveBeenCalled(); + }); + + it("contains an unavailable priority read without changing a process", () => { + const setPriority = vi.fn(); + expect( + lowerProviderDaemonPriority({ + getPriority: () => { + throw new Error("host diagnostic contains private data"); + }, + setPriority, + }), + ).toBe("unavailable"); + expect(setPriority).not.toHaveBeenCalled(); + }); + + it("contains a rejected priority write without exposing host diagnostics", () => { + expect( + lowerProviderDaemonPriority({ + getPriority: () => 0, + setPriority: () => { + throw new Error("host diagnostic contains private data"); + }, + }), + ).toBe("unavailable"); + }); +}); diff --git a/apps/server/src/providerDaemon/ProviderDaemonPriority.ts b/apps/server/src/providerDaemon/ProviderDaemonPriority.ts new file mode 100644 index 00000000..923034da --- /dev/null +++ b/apps/server/src/providerDaemon/ProviderDaemonPriority.ts @@ -0,0 +1,24 @@ +import * as NodeOs from "node:os"; + +export const PROVIDER_DAEMON_PRIORITY = NodeOs.constants.priority.PRIORITY_BELOW_NORMAL; + +export interface ProviderDaemonPriorityOperations { + readonly getPriority: () => number; + readonly setPriority: (pid: number, priority: number) => void; +} + +/** Apply before provider runtime construction so ordinary descendants inherit it. */ +export function lowerProviderDaemonPriority( + operations: ProviderDaemonPriorityOperations = NodeOs, +): "lowered" | "already-lower" | "unavailable" { + try { + // Larger nice values mean lower scheduling priority. Preserve a process + // that its launcher has already assigned an even lower priority. + if (operations.getPriority() >= PROVIDER_DAEMON_PRIORITY) return "already-lower"; + operations.setPriority(0, PROVIDER_DAEMON_PRIORITY); + return "lowered"; + } catch { + // Scheduling policy is best effort and cannot become a provider outage. + return "unavailable"; + } +} diff --git a/docs/provider-daemon-priority.md b/docs/provider-daemon-priority.md new file mode 100644 index 00000000..8c4e2e5b --- /dev/null +++ b/docs/provider-daemon-priority.md @@ -0,0 +1,25 @@ +# Provider daemon scheduling + +The dedicated provider daemon lowers its CPU scheduling priority before it starts +provider runtimes. Normally launched children inherit that priority. This reduces +CPU contention with interactive work. It does not cap memory, GPU use, network +use, or agent count. + +The change applies to a new `provider-daemon` process with a valid bootstrap. +Electron, the backend, direct backend-hosted providers, the optional supervisor, +and existing external runtimes retain their current scheduling policy. +An OS failure produces a fixed warning and lets startup continue. +An already lower priority is preserved. + +Windows documents below-normal inheritance in +[Scheduling Priorities](https://learn.microsoft.com/en-us/windows/win32/procthread/scheduling-priorities). +Child programs can still change their own scheduling policy. + +Run the explicit local smoke from the repository root: + +```sh +node scripts/provider-priority-smoke.ts +``` + +It checks two disposable Node processes and leaves the caller's priority alone. +The default unit suite uses injected operations and never changes process priority. diff --git a/scripts/provider-priority-smoke.ts b/scripts/provider-priority-smoke.ts new file mode 100644 index 00000000..448cd3b8 --- /dev/null +++ b/scripts/provider-priority-smoke.ts @@ -0,0 +1,36 @@ +import { spawnSync } from "node:child_process"; +import * as NodeOs from "node:os"; + +// Opt-in synthetic process check. Only the disposable child lowers its priority. +const moduleUrl = new URL( + "../apps/server/src/providerDaemon/ProviderDaemonPriority.ts", + import.meta.url, +).href; +const childSource = ` + import { spawnSync } from 'node:child_process'; + import * as os from 'node:os'; + import { lowerProviderDaemonPriority } from ${JSON.stringify(moduleUrl)}; + const status = lowerProviderDaemonPriority(); + const child = spawnSync(process.execPath, ['-e', 'process.stdout.write(String(require("node:os").getPriority()))'], { + stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', windowsHide: true, timeout: 5000, + }); + if (status === 'unavailable' || child.status !== 0) process.exit(1); + process.stdout.write(JSON.stringify({status, parent: os.getPriority(), child: Number(child.stdout)})); +`; +const result = spawnSync(process.execPath, ["--input-type=module", "-e", childSource], { + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + windowsHide: true, + timeout: 10_000, +}); +if (result.status !== 0) throw new Error("Synthetic provider priority check failed."); +const evidence = JSON.parse(result.stdout) as { parent: number; child: number }; +if ( + !Number.isFinite(evidence.parent) || + !Number.isFinite(evidence.child) || + evidence.parent < NodeOs.constants.priority.PRIORITY_BELOW_NORMAL || + evidence.child < evidence.parent +) { + throw new Error("Synthetic child did not inherit the lower scheduling priority."); +} +process.stdout.write(`${JSON.stringify(evidence)}\n`);