Skip to content

feat(windows): choose ClaudeRipple's CPU priority, Normal by default - #117

Merged
PBJ-2 merged 32 commits into
PBJ-2:mainfrom
115dkk:fix/windows-router-priority
Oct 30, 2026
Merged

PBJ-2 merged 32 commits into
PBJ-2:mainfrom
115dkk:fix/windows-router-priority

Conversation

@115dkk

@115dkk 115dkk commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Builds on #98. #98 is merged into this branch because it fixes the Windows tests that fail on main, so test (windows-latest) can pass here. Merge #98 first; until then this diff also shows its commits.

On Windows the router and the phone host ran below Normal priority, and a busy machine starved them. This PR runs them at Normal by default and adds a setting to choose the level.

What was measured (2026-10-29, Windows 11)

  • ClaudeRippleRouter and ClaudeRippleHost are registered with Task Scheduler priority 7. New-ScheduledTaskSettingsSet without -Priority gives 7, which runs the action at BELOW_NORMAL_PRIORITY_CLASS. conhost, the PowerShell launcher and node …\router\src\index.js were all BelowNormal. CreateProcess gives a child its parent's class when the parent is Idle or BelowNormal.
  • With cargo and rustc holding every core at Normal (8 cargo and 4 rustc processes, CPU at 100%):
    • The router took 1-3 s to answer /api/status.
    • The update from 0.12.14 to 0.12.22 failed. /api/status gave no answer within runningVersion's 10 s, so install skipped the version restart. The end-to-end probe then got connect ECONNREFUSED 127.0.0.1:8790 from a router that was listening: its accept queue was full.
    • clauderipple restart reported ! not restarted: the scheduled task belongs to another ClaudeRipple installation. The PowerShell listener lookup in router-state.ts (5 s timeout) had timed out, and its null answer counts as "other".
    • Run again once the load dropped, every ownership gate said "ours" and the restart worked.
  • In a BelowNormal PowerShell, a child started by Start-Process was BelowNormal. Once the PowerShell raised itself to Normal, its next child was Normal.
  • A Node process run by a normal user could set itself to each of BelowNormal, Normal, AboveNormal and High. Realtime silently became High.

Changes

A Normal baseline (first commit)

  • schtasks.ts registers the task with -Priority 4 (Normal). readTaskDefinition reads Settings.Priority, and taskDefinitionMatches requires 4, so the next install registers an existing task again.
  • If Register-ScheduledTask is refused (E_ACCESSDENIED), the headless and pre-headless fallbacks keep a task that differs only in its priority. Nothing else in that fallback is loosened.
  • The launcher raises itself from BelowNormal or Idle to Normal before it starts the router, so the router starts at Normal even under an old registration. Every install rewrites the launcher, so existing installs get this with their next update. The phone host's task uses the same installAgent and renderLauncher.
  • quiet() in router-state.ts waits 15 s instead of 5 s. What a null answer means is unchanged.

The setting (second commit)

  • Config.processPriority is one of below-normal, normal, above-normal or high, the Task Manager levels. It is validated, and an absent value means normal. below-normal is what the router ran at before.
  • process-priority.ts applies it on Windows and reads back what is in effect. That can also be idle or realtime when another program changed it. A level already in effect is left alone and logs nothing; a change logs one line.
  • The router applies the setting after its logger starts and again on every config change. The phone host applies it when it starts and picks up a change at its next start, because the host does not read the router config again while it runs.
  • Other platforms are left alone and report supported: false, because raising a priority there needs privileges.
  • GET /api/status carries processPriority: { supported, setting, current, error? }.
  • The dashboard's About view has a CPU priority menu under Automatic updates, shown when the router reports supported:
    • Choosing High opens a confirm() first, and the row shows a warning while High is on.
    • When Windows reports a level other than the setting, the row says which.
    • Strings exist in all six dashboard languages. The level names follow each language's Task Manager.
  • docs/ARCHITECTURE.md gets a row in §5 for this failure and a section on the setting.

Tests

  • process-priority.test.ts:
    • Each of the four levels is set on Windows, and undefined means normal.
    • A level already in effect is not set again and is not logged; a change logs one line.
    • Other platforms are never asked.
    • A failing setPriority is reported in the state and not thrown.
    • Windows' idle and realtime values read back as names.
    • The read-only state never sets anything.
  • config.test.ts: the four levels pass validation; "realtime", "High" and 1 fail with the exact message.
  • admin.test.ts: /api/status carries the field.
  • schtasks-definition.test.ts:
    • The task matches only at priority 4, and the registration line carries -Priority 4.
    • The launcher raises itself before while ($true), keeps its BOM and CRLF, and never names AboveNormal, High or RealTime.
    • A refused registration keeps a task that differs only in priority and still throws for any other difference.
  • ui-priority.test.ts (new) runs the page's own priorityRow and savePriority:
    • There is no row without supported.
    • The menu offers the four levels, and High carries its warning.
    • A level in effect that differs from the setting is named.
    • Declining the confirm saves nothing and puts the menu back; another level saves without asking.
    • Every dashboard language has every key, with {level} once.
  • npm run typecheck and check:mod pass. The router process-priority, config, admin and ui-* tests, the CLI schtasks-definition, ownership, recovery and supervisor-start tests, and the remote platform and local-control tests give 349 tests: 347 passed, 2 skipped, 0 failed.
  • launcher.test.ts passes with refactor(router): a request's kind and a provider's capabilities are each answered in one place, and the Windows suite is fixed #98 merged in.

Not done: registering the task again on a real machine, restarting the services with this branch installed, reproducing the CPU load against it, or opening the dashboard in a browser. The row was checked through the test harness only.

🤖 Generated with Claude Code

115dkk and others added 30 commits October 24, 2026 22:46
…ompts reach every provider unaltered

Whether a request was an Auto mode classifier stage, a web search, a web
fetch or a turn was worked out on several lines of proxy.ts and never
reached the adapters, so every translated provider added to the
classifier's security prompt: ChatGPT put an identity line in front of it
and the image-generation hint after it, and the OpenAI-compatible, Google,
Grok and Anthropic-compatible paths added the identity line and the
addendum. ARCHITECTURE §2 promises that prompt stays intact.

requestKind() now decides the kind once, and the route carries it with the
model the client asked for. decide() returns the route or the reason to
refuse it in one pass, replacing unroutableReason, which re-implemented
resolve's rules to explain them; resolve() stays for callers without a
path. promptAdditions() is the one rule for what a routed system prompt
may gain, and a classifier request gains nothing. Failover names `asked`
instead of splitting the log tag.

Behaviour is otherwise unchanged. The ChatGPT path now trims the addendum
as the others did. A prefix rule naming an ingress-only provider still
lets a marker to a model nothing can take be refused by name; a test pins
that case, which folding the two functions together had briefly lost.

Implementation by GPT Daybreak Blue to a fixed design; reviewed here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five deepening candidates with a before/after flow graph each, a top
recommendation, and what should be left alone. The router change before
this commit is its candidate 1; candidate 3 comes next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings PBJ-2#91's review fixes and current upstream main. The two conflicts kept both sides: upstream's conversation key on the request record and the SSE parser tests, beside this branch's request kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the comment on the first-stage bound from PBJ-2#91.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings current upstream main through PBJ-2#91: provider-qualified model ids and the
fix that keeps a Claude request with a delegation marker passing through (PBJ-2#94).

Upstream made that fix in resolve() and unroutableReason() side by side. Here
both live in decide(): the qualified-id branches are placed as upstream placed
them, and every way of finding no route ends in one noRoute() that applies
upstream's refusal rules in their order. Checked against PBJ-2#91's resolve() and
unroutableReason() over 41,664 combinations of config, model and body (16,124
routed, 10,545 passed through, the rest refused with all five messages): no
difference. The upstream tests that called unroutableReason() now read
decide(...).refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 19 tests that fail on main's Windows job, and why:

- Same folder, different spelling. The runner's TEMP is the 8.3 alias
  C:\Users\RUNNER~1\...; the product records the native long form, and on
  Windows the lowercased installation home. desktop-legacy, direct-ownership,
  the Codex block and the git creation tests now build their fixture roots with
  fs.realpathSync.native and compare through the product's own normalizeHome.
  The desktop-legacy fixture also sets USERPROFILE, which os.homedir() reads on
  Windows, so its default-home case stops seeing the runner's real home.
- A file name Windows forbids. The launcher simulations for darwin and linux
  made a home named with a double quote; on a Windows host it keeps the other
  hostile characters (Korean, an apostrophe, spaces) and drops only the quote.
  The three that need symlinks skip where the account cannot make one.
- The browse test expected the listing without the drive entries the server puts
  first on Windows (folders.test.ts already pins those); it now expects them.

Product fixes the failures exposed:

- images.ts: the session temp scope and the allowed-root checks mixed TEMP's
  8.3 spelling with the long form Windows returns, and compared paths case by
  case, so a legitimate pasted image came back "blocked". ACL and identity
  checks now run on the native long form, Windows paths compare without case,
  and TEMP itself is still refused when it is a junction. The ACL, junction,
  symlink and identity re-checks are unchanged.
- tray.ts: on Windows renameSync replaces an existing file, so a backup path
  already taken let the swap replace the working runtime. The swap now refuses
  an occupied backup path and leaves both copies whole.
- launcher.ts: removing the tray launcher uses unlinkSync with ENOENT ignored;
  fs.rmSync({ force: true }) on that hostile file name ended the Node 24.1
  process with 0xC0000409 on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
targets.ts answers the questions the router asked in a dozen places:

- isTargetProvider / ingressOnly: whether a provider can take routed
  requests. It was spelled two ways ("anthropic without accountPool" and
  "accountPool with claude-code auth") across routing, validate, the proxy,
  agent files, model scores and the admin status. One spelling now, the
  stricter one, which a valid config never tells apart.
- declaredBy / qualifiedModel / rawDeclaredBy / soleOwner, moved from
  routing.ts (still re-exported there).
- effortLevels, moved out of the HTTP module the proxy imported it from
  (still re-exported from admin.ts).
- runsServerTools / answersWebSearch: the proxy's canRunServerTools and the
  admin's hand-copied mirror of it become one implementation.
- targetWarnings: a primary route or a direct rule naming an ingress-only
  provider was accepted by validate and then dropped by routing without a word.
  It is still accepted (refusing it would break configs that load today), but
  the config save answers with a warning per rule and the router logs it when
  it reads the config.

The dashboard asks the same question of providers not saved yet, so it keeps
one copy, isTargetProvider in app.js, used where it had three inline checks;
ui-targets.test.ts holds that copy to the router's for every provider shape.

One difference that comes with the single spelling: a config with accountPool
true and an auth other than claude-code is refused by validate but adopted at
startup. It is now ingress-only everywhere; before, routing treated it as a
target and the proxy then answered 400.

decide() still matches the previous rules over the 41,664-case comparison.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The replacement race in images.test.ts swapped the folder only when the reader
  opened one exact spelling of the path. The reader now opens the native long
  form, so on a runner whose TEMP is an 8.3 alias the swap never happened and
  the file read as "ready". The swap now triggers on either spelling, and on
  Windows it uses a junction, which needs no symlink privilege, so the test also
  runs on accounts without it (it passes here: the swap is blocked).
- The darwin/linux launcher simulation checked that a symlinked home is the same
  installation. normalizeHome resolves links for POSIX homes only; on a Windows
  host the simulated homes are Windows paths, compared as text, so that one step
  runs off Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Windows session temp check required each folder's owner to be the current
user. Folders made by an elevated administrator token belong to the
Administrators group instead, so on such a machine (GitHub's Windows runners
among them) every pasted image and scratchpad file was blocked from the phone.
The owner may now be the user, SYSTEM or Administrators: the principals the same
check already trusts in its access rules. A folder another ordinary user owns,
or one writable by anyone else, is still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two conflicts, both kept from each side:
- admin.ts: the config save passes the agent dir to the model-slot sync,
  as main now does, and still appends the target warnings.
- proxy.ts: main's pool-affinity capture and credential logging stay;
  the web-search query is still read from the request kind decided once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test added in 3f908e7 moved HOME to a temporary folder, but on
Windows os.homedir() reads USERPROFILE, so the reader looked in the
real home and the test failed there. It now sets both and restores
each, unset ones included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three conflicts, each kept from both sides:
- agents.ts: main moved the worker loop into agentTargets(); the loop
  there now asks isTargetProvider, as this branch's copy did.
- providers/google: handle() takes main's credential handoff and this
  branch's request kind, in the order the OpenAI adapter takes them.
- proxy.ts: the route still comes from decide() once, and main's worker
  ladder fallbacks then apply to it. canRunServerTools stays removed
  (targets.ts answers it); main's new ladder and model-health helpers
  are kept. The Google call passes both the credential and the kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The legacy-evidence test was skipped wherever file symlinks cannot be
made, though only its linked-home check makes one, and that check was
already limited to POSIX hosts. The linked-home check is now a test of
its own, skipped on Windows, and the legacy and unknown-file assertions
run on every account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dle()

The classifier tests for the OpenAI-compatible and Google translations
called promptAdditions() directly, so they would not notice the request
kind being lost on its way into the adapter. The OpenAI (both wires),
Google and Grok adapter tests now pass the kind from requestKind() into
handle(), as the ChatGPT adapter test already did, and send a classifier
request and an ordinary turn through an adapter with an addendum: the
classifier's text arrives exactly as sent, and the turn still gets the
identity line and the addendum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No conflicts. Main now holds PBJ-2#91, so the PR's diff against main is the
refactor and the Windows fixes alone. Main's new code repeats none of
the target, server-tool or prompt rules this branch moved into
targets.ts and identity.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's harness (495df86) now runs the page's own renderAutoClassifier,
which on this branch asks isTargetProvider whether a Claude provider is
an account pool. The harness slices that function from app.js too, so
the redraw test runs the page's code instead of failing on a missing
name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A scheduled task registered without a priority runs at Task Scheduler's
default 7, BELOW_NORMAL, and the launcher's children inherit it. On
2026-10-29, with cargo and rustc holding every core at Normal, the router
took 1-3 s to answer /api/status and refused new connections: an update's
probe failed with ECONNREFUSED on a listening port, `install` skipped the
version restart because /api/status did not answer in 10 s, and `restart`
called the router "another ClaudeRipple installation" because the 5 s
listener lookup timed out.

- The task is registered with -Priority 4 (Normal). The definition read
  includes the priority, so an existing task is registered again by the next
  install; where Register-ScheduledTask is refused, a task that differs only
  in its priority is kept.
- The launcher raises itself from BelowNormal or Idle to Normal before it
  starts the router, so the router gets Normal under an old registration too.
- The router and the phone host raise themselves to Normal at start on
  Windows and log it.
- The listener lookup behind the restart's ownership check waits 15 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fix before this one only raised the router to Normal. This makes the level
a setting with the four Task Manager levels: below-normal (what Task Scheduler
gave the router before), normal (the default), above-normal and high.

- Config.processPriority, validated to those four; absent means normal.
- process-priority.ts applies it on Windows and reads back what is in effect
  (idle and realtime included, when another program changed it). The router
  applies it after its logger starts and on every config change; the phone
  host applies it when it starts. Other platforms report supported: false.
- GET /api/status carries processPriority { supported, setting, current,
  error? }.
- The dashboard's About view has a CPU priority menu under Automatic updates.
  High asks before it is saved and keeps a warning on the row while it is on;
  a level Windows reports otherwise is named. Strings in all six languages,
  with each language's Task Manager names.
- docs/ARCHITECTURE.md: the failure row and a section on the setting.

The Task Scheduler -Priority 4 registration and the launcher's raise stay: they
give every start a Normal baseline before the router reads its config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"CPU를 계속 쓰는 문제가 생기면" brings writing to mind before using. The High
warning and its confirm now say "리소스를 과도하게 쓰면".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This PR builds on PBJ-2#98, which fixes the Windows tests that fail on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The deferred cleaner's PowerShell did not write "running" within 5 s on a
Windows CI runner (the run of PBJ-2#116, 2026-10-29), and uninstall --purge reported
that cleanup could not start. A PowerShell start took over 5 s on a loaded
desktop the same day. The cleaner now has 20 s to be launched and 15 s to
answer; a working one still returns as soon as it answers. The test's child
gets 60 s so that the longer waits fit inside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt wait

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The deferred cleaner writes its receipt and then exits; until it has, Windows
keeps its redirected logs open. Removing the folder at once failed with EPERM
in the self-purge test's after hook on a CI runner (2026-10-29), although
every assertion had passed. Both cleaner tests now remove it through one helper
that retries for up to 5 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p retry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The snapshot test makes directOn's backup fail by taking the backup's name
first. The name carries the time to the second, and on a Windows CI runner
the second turned over between the test's name and directOn's, so nothing
failed ("Missing expected exception", 2026-10-29). The test now takes this
second's name and the next two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a direct test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Repeating the purge tests 20 times on 2026-10-29 failed in three ways, each
also reachable by `uninstall --purge` itself:

- The CLI read the receipt while the cleaner's Set-Content was writing it,
  got EBUSY, and reported a cleaner that never started. A busy or empty
  receipt is now read again on the next round.
- After Wait-Process returned, Get-Process by id still listed the exited Node
  process, and the receipt said "uninstall process has not exited". The
  cleaner now holds that process's handle and waits on HasExited.
- Set-Content could not write the final receipt while a reader had it open,
  so a purge that had removed the folder recorded itself as failed. The
  receipt is now written with WriteAllText, which shares the file with
  readers, and a refused write is tried again.

The tests read a receipt that is mid-write as not yet there, and remove the
cleaner's folder with their own retry: rmSync's maxRetries does not cover the
EPERM of its still-open logs on Node 24. 30 runs in a row passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ver tests

On PBJ-2#98's Windows run at f5b9cbd, two permission tests hit their 4.5 s limit
and a socket retry test ran out of waitFor's 2 s while the host was still
answering; the same tests had passed on the runs before. The limits are for a
stalled test, and a passing one never waits them out, so each file now uses
TEST_TIMEOUT (15 s) and WAIT_LIMIT (10 s). 211 tests pass locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Windows CI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
115dkk and others added 2 commits October 29, 2026 22:32
…home

The Windows task lock lives under os.homedir(), which reads USERPROFILE on
Windows. ownership.test.ts and service-races.test.ts set HOME only, so both
took the real home's lock for the default task name, in parallel: on PBJ-2#117's
Windows run, service-races found ownership's lock before its owner file was
written and failed with "서비스 작업 잠금의 소유자를 확인할 수 없습니다"
instead of the race it tests (2026-10-29). Both now point USERPROFILE at their
temp folder too, as desktop-legacy.test.ts does; neither touches
~/.clauderipple/service-locks any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@PBJ-2
PBJ-2 merged commit a19a33d into PBJ-2:main Oct 30, 2026
3 checks passed
PBJ-2 added a commit that referenced this pull request Oct 30, 2026
#98's Windows timeouts arrived on main through #98 and again through #117's
copy of it, so the merge kept both and the remote tests stopped compiling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants