Commit Graph

2004 Commits

Author SHA1 Message Date
David Metcalfe e4d0e4c3d8 fix(win): never rewrite the in-use managed Node tree (#80926)
The Hermes-managed Node tree at %HERMES_HOME%\node is destructively
rewritten while the desktop app's Node processes execute from it:
the Node-26 heal did shutil.rmtree + move, the EBADENGINE repair ran
npm install --global --prefix into the tree, and install.ps1's
Test-Node did Remove-Item + Move-Item. Windows rejects those writes
with PermissionError: [WinError 5] on npm.cmd.

- _heal_managed_node_windows: stage the fully-downloaded tree in a
  sibling node.new-* dir, then rename-swap (live tree -> node.old-*,
  staged -> node). The live tree is never deleted before its
  replacement is ready, so an interrupted heal cannot gut it; a
  refused rename is the OS-level in-use signal and defers (returns
  None) instead of forcing the write.
- heal_hermes_managed_node: an in-use deferral does not record the
  once-per-process attempt, so the heal retries once the tree is free.
- managed_node_tree_in_use: cheap psutil pre-check (Windows only) that
  avoids pointless 30-50MB re-downloads in long-lived processes.
- upgrade_managed_npm: defer the in-place npm self-upgrade while the
  tree is in use, with a notice.
- install.ps1: Test-ManagedNodeInUse guard around Update-ManagedNpm and
  the Test-Node install branch, which now rename-swaps instead of
  delete-then-move.

An in-use-but-outdated tree keeps serving the old runnable Node (old
Node beats no Node), and every npm resolution re-evaluates the heal, so
the upgrade applies automatically on the next update with the app
closed.
2026-08-14 21:35:30 -07:00
Teknium 20e5d51bea fix(browser): managed-first browser-use CLI resolution
Everything Browser Use is now managed by Hermes: the canonical binary
is the one install_cli() provisions into HERMES_HOME/bin, and every
resolution and provisioning site prefers it.

- _find_cli(): probe order flipped to managed bin -> PATH ->
  user-level tool dir (then uvx across the same order). A user's own
  uv tool install can no longer shadow the Hermes-managed copy with a
  drifted version; side installs only matter when we have nothing.
- install_cli(): a browser-use on PATH no longer short-circuits the
  install — only the managed copy does, so selecting any backend
  provisions the copy Hermes controls and updates.
- _ensure_browser_use_cli() (hermes tools): drops its own PATH check
  and always delegates to install_cli(), the single owner of the
  managed-copy policy.
- install.sh / install.ps1: same short-circuit fix — only
  HERMES_HOME/bin/browser-use counts as installed.

Follow-up to #86240 and #86320: with every non-Camofox backend
selection installing the CLI, managed-first closes the remaining
version-drift/shadowing class instead of guarding single sites.

Tests updated to pin the new contract: managed beats PATH and
user-local; PATH install does not satisfy install_cli; helper always
delegates. E2E-verified precedence chain with real files and a real
degraded-PATH install attempt.
2026-08-14 13:51:07 -07:00
Sascha 5bfb7ee42f fix(desktop-update): drive the Windows hand-off through the venv python, not the hermes.exe shim
`uv pip install -e .` has to replace the console-script shims, so
_quarantine_running_hermes_exe must first rename the running hermes.exe out
of the way. That rename fails whenever any child process spawned from that
hermes.exe is still alive: on Windows a child inherits a handle on the parent
image. It is the inherited handle, not the trampoline, that pins the file --
killing the child makes the identical rename succeed, and the shim flavour
(uv trampoline vs distlib launcher) makes no difference.

The updater spawns such children itself (npx cache warm, memory-provider
refresh -- hindsight-api runs as a daemon with --idle-timeout 300 and outlives
the step that started it), so this presents as a race rather than a hard
failure: the same hand-off succeeds on one run and dies on the next. Step 2's
shim-unlock preflight cannot catch it, because the shim genuinely is unlocked
at that moment; the pinning child appears later, during the update.

When the rename loses that race, _schedule_replace_on_reboot is the last
resort -- and MOVEFILE_DELAY_UNTIL_REBOOT writes to HKLM, so it needs
elevation. A Desktop-driven update is not elevated, so it returns
ERROR_ACCESS_DENIED, `uv pip install -e .` exits 2, and the ZIP fallback
repeats the identical sequence. The desktop build stage is then never reached
while the pre-build clean has already removed apps/desktop/release, leaving an
install whose Start Menu shortcut points at a Hermes.exe that no longer exists.

Running the same code as `python.exe -m hermes_cli.main update` puts the
inherited handles on python.exe, which uv never has to replace.

posix.sh is deliberately untouched: unlinking a running executable is legal
there, so the equivalent call is harmless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:48:31 -05:00
AlexMnrs ca1b3f8705 fix(windows): avoid locale-sensitive update timestamps 2026-08-14 15:41:38 -05:00
ethernet cd667debfa fix(install-e2e): windows transcripts were empty; player gets #zip= hash + one player per run
Windows transcripts were ZERO bytes: ts-prefix.ps1 formatted with
{0:D2}, but Floor() returns a double and the D specifier is
integer-only - it threw per line, and under the driver's relaxed EAP
every line errored into the void. {0:00} fixes it (custom numeric
format works on doubles). Reproduced the exact pipeline locally
(empty file + Format specifier invalid), verified the fix produces
prefixed merged stdout+stderr with exit code intact. That is also
why the log timeline never auto-synced: there was nothing in the
files to sync.

The GitHub artifact URL 307s to /suites/... server-side and strips
the ?zip= query param. The player now reads the zip URL from a
#zip= HASH param (client-side, survives the redirect) with ?zip=
as fallback; the hash path was verified in a real browser against a
real leg zip (auto-fetch + boot).

Per ethie's design, one player artifact for the whole run: new
leg-player job uploads playback.html (archive:false) before the
matrix legs, the report job needs it, and each ran cell gets TWO
links - 📼 to the player with #zip=<that leg's logs zip> and ⬇️ to
the raw zip. Per-leg player uploads removed from all three run
workflows.
2026-08-13 22:51:44 -04:00
ethernet 08d9f27773 fix(install-e2e): results chart 📼 links against real artifact names
Debugged against run 31635036702 (real jobs + artifacts replayed
through the renderer). Two naming realities the renderer ignored:

1. upload-artifact with archive:false IGNORES the name: input and
   registers the artifact under the FILE's basename - every leg's
   player artifact is called 'playback.html' (all the same blob). The
   renderer now uses any 'playback.html' artifact for the player half
   of the 📼 link instead of install-e2e-player-<leg_id>.
2. The posix arms append -<sha> to the logs artifact name at upload
   (install-e2e-logs-<leg_id>-<sha>), while windows does not. Match
   by prefix instead of exact name.

Also fixed the pass/fail summary counters, which compared cells with
=== against '&#x2705;' - the appended reel link made every ran cell
count as neither passed nor failed (the summary said 0 passed while
the table was full of checks).

Replayed against the real run: 31 passed, 25 failed, 149 skipped,
24 📼 links on ran cells (both outcomes), none on skips.
2026-08-13 14:41:04 -04:00
brooklyn! 6a198f8a12 fix(install): a failed Node dependency install now fails the install instead of printing success (#85537)
* fix(install): fail when Node dependencies cannot install (#85297)

The POSIX installer converted root and TUI npm failures into warnings, then
printed a dependency-success message and reached the installation-complete
banner with a zero exit status. This left consumers with no usable
node_modules while reporting success.

Treat both required npm installs as fatal: log an error, restore tracked
lockfile churn, return status 1, and propagate the failure from the monolithic
and node-deps stage callers. Successful installs, Termux and missing-Node
skips, missing-manifest skips, and optional Playwright/Browser Use/Computer
Use best-effort behavior remain unchanged. The fix is limited to the POSIX
installer; the PowerShell installer is outside this issue's scope.

Focused and adjacent installer tests passed (32), with bash syntax,
py_compile, and diff checks clean. The broader installer family had 90 passes,
one unrelated pre-existing failure, and two skips; the full suite was
environment-limited by missing dependencies. CodeRabbit, iterative deep
security/compatibility reviews, and final confidence security/compatibility
reviews were clean against the final diff.

Fixes #85297

* fix(install): require npm alongside node in check_node (#77003)

A stray `node` symlink without a sibling `npm` (leftover from a node
version manager) made check_node report "Node.js found"; every later
npm install then failed and the desktop build died with an opaque
"Node.js / npm unavailable". Node now only counts as found when npm
resolves on the same PATH, with an explicit "stray node symlink?" branch
that falls through to the Hermes-managed Node (which bundles npm).

The overlapping success-log honesty half of the original PR is subsumed
by the previous commit, which makes a failed npm install fatal rather
than conditionally-logged; the behavioral tests there cover it, so this
commit keeps only the check_node PATH-gate assertions.

Fixes #77003.

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

---------

Co-authored-by: Eugeniusz Gilewski <egilewski@egilewski.com>
Co-authored-by: CriptoGus <128640021+criptogus@users.noreply.github.com>
Co-authored-by: criptogus <criptogus@users.noreply.github.com>
2026-08-13 18:39:54 +00:00
brooklyn! d753957e8a fix(install): Windows setup no longer hangs forever on Node.js dependencies (#85529)
* fix(install): time-box the Windows node-deps stage so a stalled npm or Playwright install can't hang setup forever

scripts/install.sh has bounded this same work with run_with_timeout
"$NODE_DEPS_TIMEOUT" (600s default) since #39219, but install.ps1 never got
the guard: Install-NodeDeps ran both `npm install` and `npx playwright
install chromium` unbounded. A stalled registry fetch or a wedged Chromium
archive extraction (#76222, #84614) froze the installer indefinitely -- one
user left it running 12+ hours overnight before asking for help.

Route both invocations through _Invoke-NativeWithTimeout: cmd.exe launches
the native command with its output merged to a log, the parent polls with a
wall-clock deadline and tails new log lines to the console each tick (the
live progress that makes a 3-minute download distinguishable from a hang),
and on timeout taskkill /T /F kills the real process tree and returns 124 --
the same convention as coreutils timeout and bash's run_with_timeout.
Wait-Job was rejected for this: jobs swallow live output and Stop-Job leaves
the npm child running. Windows PowerShell 5.1-safe throughout.

Timeouts surface as a warning with the log path, a note that re-running the
installer resumes (stages are idempotent), and the NODE_DEPS_TIMEOUT env
override for slow links -- mirroring bash.

Fixes #76222.
Closes #84614.
Supersedes #76303.

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

* fix(installer): roll stage timers over to hours so an overnight stall doesn't read as "744 hours"

formatElapsed rendered a running stage as m:ss with unbounded minutes: a
node-deps stage left hanging overnight showed "744:38", which the user who
reported the hang understandably read as 744 hours. formatDuration
(completed stages) had the same unbounded-minutes shape.

Move both formatters into src/lib/format.ts (pure, no React) and add the
hour rollover: h:mm:ss live, "Xh Ym" completed. tests-js pins the shapes,
including 744m38s -> 12:24:38.

---------

Co-authored-by: JonthanaHanh <JonthanaHanh@users.noreply.github.com>
2026-08-13 13:38:20 -05:00
Teknium 7060ac7bed feat(computer-use): provision cua-driver at install time and on toolset enable
Choosing Computer Use should be a config flip, not a hunt for
'hermes computer-use install'. Three provisioning rungs:

- install.sh / install.ps1 pre-install cua-driver (best-effort,
  non-fatal, time-boxed at 660s above the upstream installer's 600s
  lock window; --skip-computer-use / -SkipComputerUse to opt out;
  Termux and unwritable-/Applications skipped cleanly)
- PUT /api/tools/toolsets/{name} (dashboard + desktop toggle) spawns
  the background 'hermes tools post-setup cua_driver' action when the
  toolset is enabled while the binary is missing — previously the
  toggle 'saved' but the tool never appeared in the schema because
  check_computer_use_requirements() couldn't find the binary
- hermes tools interactive flow already installed via
  _toolset_needs_configuration_prompt/_POST_SETUP_INSTALLED (unchanged)

Docs: computer-use.md enabling section rewritten around the new flow;
installation.md documents --skip-computer-use.
2026-08-13 02:44:48 -07:00
Zak B. Elep 793f0b3ff1 fix(install): stop npm-installing agent-browser eagerly in install.sh/install.ps1
ensure_browser() (install.sh) and Install-AgentBrowser (install.ps1)
are reached only via the explicit --ensure browser / -Ensure browser
on-demand mode, itself only triggered by an actual browser-tool call's
lazy-install fallback or `hermes acp --setup-browser`. agent-browser
already resolves via npx in that same fallback before ever reaching
these scripts, so eagerly npm-installing a second, separately
version-pinned copy here was redundant and an extra credential/
supply-chain surface for a path npx already covers. Chromium
acquisition for this on-demand path is now deferred entirely to
_maybe_autoinstall_chromium's existing lazy fallback. camofox's
install and system-browser detection/configuration are unaffected.
install.ps1 also drops the now-dead -SkipChromium switch, confirmed
unused at its one call site.
2026-08-13 02:38:28 -07:00
ethernet e138cb555d feat(install-e2e): hook the leg player into the results table
Each leg uploads playback.html as a single-file artifact (archive:
false) before the driver runs, so it exists even on failure. The
results chart now links every leg that RAN (pass or fail, not skip)
to its player with ?zip= pointing at that leg's logs artifact.

Leg<->artifact mapping: the generator mints a leg_id per matrix entry
(sanitized matrix name, exported legId()), every run workflow names
its artifacts install-e2e-{player,logs}-<leg-id>, and the report job
feeds the run's artifact name->id list to the results renderer, which
rebuilds the leg id from the parsed job name. GitHub does not link
jobs to artifacts, so the deterministic name is the join key.

Empirical finding: GitHub artifact downloads are auth-gated (the
download URL 307s to /suites/... which is 404 anonymous), so a
locally-opened player page cannot fetch the zip cross-origin. The
player now degrades gracefully: ?zip= fetch failure renders a real
download link for the zip (a normal click carries the user's session)
plus a drag-and-drop / file-picker path, and no-param opens as a pure
drop target. Verified in a real browser against a real artifact URL.

Verified: generator emits leg_id, results renderer emits
✅/❌ [📼](...?zip=...) only on ran cells, npm run check PASS,
install tests 36/36, strict tsc PASS, actionlint x4 PASS.
2026-08-12 15:50:46 -04:00
brooklyn! 9eab7a4473 fix(ci): stop running uv lock --check on PRs that can't touch the lockfile (#84675) 2026-08-12 13:30:01 -05:00
ethernet 4578b5d0a6 ci(install-e2e): result chart says WHY a cell skipped
Skipped cells split into their reason: pre-desktop (a desktop-surface
method against a tag that predates apps/desktop) vs TODO (declared,
no driver arm yet). The report job passes pick-releases' annotated
tags into --format results; the shared methodNeedsDesktop() is the
same predicate the plan chart uses, so plan and results agree about
what pre-desktop means. Without --tags the renderer keeps the flat
skip label (backward compatible).

Verified against run 31579084845 real job list: 82 legs, 44 skips
labeled correctly.
2026-08-12 10:40:57 -04:00
Teknium f20d16fbf1 fix(windows): SSH ControlMaster gating + stop hijacking the user's python (#84452)
* fix(windows): SSH ControlMaster gating + stop hijacking the user's python

Two Windows environment-integrity fixes:

1. tools/environments/ssh.py (#73927): Windows OpenSSH has no
   Unix-domain-socket ControlMaster support, so unconditionally passing
   ControlPath/ControlMaster/ControlPersist failed EVERY tool call on a
   Windows-hosted ssh terminal backend with 'getsockname failed: Not a
   socket'. Gate the three multiplexing options behind a module-level
   _SSH_MULTIPLEX = (os.name != 'nt'); the scp upload path is gated the
   same way. On Windows the backend now works without connection pooling
   (each command a fresh connection); POSIX behavior is unchanged. The
   teardown 'ssh -O exit' is naturally inert because the socket never
   exists on Windows.

2. scripts/install.ps1 (#83797): the installer put the whole
   venv\Scripts directory on the user PATH, which contains python.exe /
   pythonw.exe / pip.exe and so silently hijacked the 'python' command in
   every terminal on the machine — unrelated projects started resolving
   python to Hermes' runtime interpreter. Now copy only the launchers
   (hermes.exe, hermes-acp.exe) into a dedicated $InstallDir\bin and put
   THAT on PATH. Existing installs are migrated: the legacy venv\Scripts
   entry is stripped from the user PATH on the next install/update. The
   new bin dir is under $InstallDir (…\hermes-agent), which the uninstall
   PATH sweep already matches via its \hermes-agent marker.

Updated the stale hermes_cli/update_cmd.py docstring that described the
old venv\Scripts-on-PATH layout.

Tests: SSH ControlMaster gating pinned both directions (multiplex on →
flags present; off → absent but BatchMode/StrictHostKeyChecking retained).
install.ps1 parses clean via the PowerShell AST parser.

* docs: update windows-native install docs for the bin\ launcher layout

CI (test_windows_native_docs) pins the docs and installer to the same
PATH layout. The #83797 fix moved the PATH entry from venv\Scripts to a
dedicated $InstallDir\bin holding only the hermes launchers, so update
the Windows-native guide to match: PATH-after-install section, the
install-steps list, the directory-layout table, the Get-Command
verification line, and the 'command not found' pitfall. Test now asserts
the bin\ layout and guards against a regression back to venv\Scripts on
PATH.

* fix: keep install.ps1 pure ASCII (PowerShell 5.1 codepage safety)

The two comments I added in the #83797 PATH-hijack fix used em-dashes,
tripping tests/test_install_ps1_ascii_only.py — Windows PowerShell 5.1
reads a BOM-less .ps1 in the system ANSI codepage (not UTF-8), so a
non-ASCII byte can misdecode into a stray quote and desync the parser
(issues #66994/#67000). Replace the em-dashes with ASCII '--'.
2026-08-12 02:56:33 -07:00
ethernet 2b39b885d6 test(install-e2e): macos desktop-installer arm - the published dmg, driven for real
macos gains the desktop-installer@latest install method: the website's
Hermes-Setup.dmg (verified live), mounted with hdiutil and its app
binary run DIRECTLY - an open-launched app inherits none of the git
redirect env, so direct exec is what keeps the isolation honest while
staying the same binary and first-launch flow.

install-e2e-macos-run.yml takes the windows shape: one workflow, one
inner job per driver arm, native skips. Arm 1 delegates script installs
to the shared OS-agnostic run workflow; arm 2 stages, installs from the
dmg, and drives both app-update methods through launch-from-spec.mjs -
open-app-update launches the installed .app (the double-click surface,
env via Playwright), hermes-desktop-app-update captures the product's
own hermes desktop spawn. Both end on sha asserts, never version
strings.
2026-08-12 04:36:03 -04:00
ethernet adf7d55f4b ci(install-e2e): windows composes install x update - one driver, one job
windows-desktop-gui-e2e.ps1 and windows-installer-script-e2e.ps1 fold
into tests/install/windows-e2e.ps1 with orthogonal -InstallMethod and
-Route axes: the install phase dispatches on one, the update phase on
the other, and shared workroot state carries how OLD landed - so any
implemented update method can follow any implemented install method.
Implementing a new pair is now a driver function plus a gate edit,
never a new job.

The run workflow collapses to ONE inner job whose if: is the
implemented-pairs table. Newly cheap pairs go live with the merge:
  desktop-installer@latest -> hermes-update / installer-script /
    installer-script+desktop / hermes-desktop-app-update
  installer-script(+desktop) -> hermes-desktop-app-update
  installer-script+desktop -> open-app-update (the -IncludeDesktop
    install registers real Start Menu / Desktop shortcuts)
Only desktop-installer@latest as an UPDATE method stays a declared
TODO. scripts/windows_e2e_harness.ps1 executes the parse/parameter/
dispatch checks under pwsh before any Windows runner spins up.
2026-08-12 04:30:12 -04:00
ethernet 0f903e14a3 test(install-e2e): hermes-desktop-app-update goes live on the script driver
Playwright must own the spawn (it needs the inspection pipe), but
hermes desktop is not just build+launch - stamp checks, integrity
gates, sandbox fixups, and a constructed child environment. So the
driver intercepts the product's own launch: a sitecustomize.py on
PYTHONPATH (opt-in via HERMES_E2E_CAPTURE_LAUNCH) wraps subprocess.run,
captures argv/cwd/env at the spawn site, and fakes success instead of
spawning; launch-from-spec.mjs then _electron.launch-es exactly that
spec and clicks Settings -> About -> Update now. Completion is product
state, not a Playwright event: the handoff result file or the checkout
reaching the expected sha (source installs write no result file).

Ships with the driver, so it works unchanged on every sampled OLD ref
- no product flag, no pre-flag fallback split. Both launch shapes are
matched (npm exec electron / packaged exe under apps/desktop/release);
npm BUILD calls pass through untouched. Exit 0 without a capture fails
the leg: a version that never reached its launch must not pass.

Probe-the-probe: scripts/launch_capture_probe.sh runs control rows
(no opt-in, non-launch argv) and both treatment shapes - all green
locally. Gate flips on the shared run workflow for linux/macos;
windows adopts the same path with the driver restructuring.
2026-08-12 04:20:56 -04:00
ethernet 239523414e test(install-e2e): installer-script+desktop is its own install and update method
The one-liner with its desktop stage opted in (--include-desktop /
-IncludeDesktop) is a real install kind, distinct on both sides:
on windows the stage builds Hermes.exe AND registers Start Menu /
Desktop shortcuts - a second path to a hand-launchable app - while
on linux/macos it builds into the checkout and registers no OS
entry point.

Declared on every OS and driven by both script drivers: the drivers
pass the flag through (hard failure if the ref predates it - the
tag-has-desktop gate already skips pre-desktop tags upstream) and
assert the built app exists under apps/desktop/release afterwards.
The run-workflow gates run +desktop pairs only on desktop-bearing
tags; app-update pairs from +desktop installs stay declared TODOs.
2026-08-12 04:01:16 -04:00
ethernet 1af2093663 test(install-e2e): split app-update into open-app-update + hermes-desktop-app-update
The desktop app has two launch paths, so app-update becomes two
methods. open-app-update starts the app from the OS entry point the
desktop installer created (the installed exe / the .app), so it exists
only where a desktop installer does. hermes-desktop-app-update starts
the app via hermes desktop, which every install method provides on
every OS that ships the desktop app - on linux it is the only app
surface, since no desktop installer or packaged artifact exists there.

Both variants are desktop-surface methods on every OS, so the
tag_has_desktop annotation moves from windows-only to every matrix
entry, install-e2e-run.yml grows the input, and the plan chart marks
pre-desktop cells on all OSes.

The windows GUI arm's implemented pair renames to open-app-update;
every other new combination is a declared TODO that natively skips.
2026-08-12 03:47:27 -04:00
ethernet 3cd6636471 fix(install-e2e): stray paren broke the generator - every node invocation died 2026-08-11 23:24:00 -04:00
ethernet 8e6f6d863c ci(install-e2e): result chart on the run summary - conclusions per combination x tag
generate-e2e-matrix.mjs grows --format results: reads the run's own
job list as NDJSON {name, conclusion} on stdin (per-leg conclusions
are NOT reachable through needs - a matrix job collapses to one
aggregate result) and re-renders the plan chart with each cell's
outcome. Legs are recognized by the exact name shape buildMatrices
mints, so unrelated jobs fall out; duplicate leg names (one windows
job per driver arm, only one runs) merge by significance - real
outcomes beat skips, failures beat successes. A final report job
(if: always, needs all three OS jobs) appends the chart to its step
summary via gh api with the default token.

Verified against two real runs: 31536931863 renders 11 passed / 0
failed / 54 skipped all-green; 31557865241 (the pre-EAP-fix run)
renders its 4 real failures + cancellations over the sibling arm's
skips.
2026-08-11 23:19:45 -04:00
ethernet db969ce696 ci(install-e2e): result chart on the run summary - conclusions per combination x tag
generate-e2e-matrix.mjs grows --format results: reads the run's own
job list as NDJSON {name, conclusion} on stdin (per-leg conclusions
are NOT reachable through needs - a matrix job collapses to one
aggregate result) and re-renders the plan chart with each cell's
outcome. Legs are recognized by the exact name shape buildMatrices
mints, so unrelated jobs fall out; duplicate leg names (one windows
job per driver arm, only one runs) merge by significance - real
outcomes beat skips, failures beat successes. A final report job
(if: always, needs all three OS jobs) appends the chart to its step
summary via gh api with the default token.

Verified against two real runs: 31536931863 renders 11 passed / 0
failed / 54 skipped all-green; 31557865241 (the pre-EAP-fix run)
renders its 4 real failures + cancellations over the sibling arm's
skips.
2026-08-11 23:16:09 -04:00
ethernet 80a0a198dd ci(install-e2e): plan chart on the run summary - combination x tag markdown table
generate-e2e-matrix.mjs grows --format markdown: one row per
{os, install -> update} combination, one column per starting tag,
appended to GITHUB_STEP_SUMMARY by the expand job. Cells mark
dispatched legs; run-vs-grey stays the run workflows' call, so the
only special cell is pre-desktop (the one annotation the plan owns).
JSON mode unchanged.
2026-08-11 23:06:42 -04:00
brooklyn! ed0e707914 Merge pull request #83634 from NousResearch/bb/handoff-window
Detached update hand-off on every OS: quit → hermes update → reopen, with one dumb shim window
2026-08-11 20:32:12 -05:00
ethernet ea4cd375f8 ci(install-e2e): retire the bubblewrap sandbox - git redirect everywhere, macos legs live
The fake Internet (bubblewrap + slirp4netns + MITM proxy +
upload-pack shim, 883 lines across dev-sandbox.sh, stage2-run.sh,
proxy.py, ssh-shim.sh, openssl.cnf, install-update-e2e.sh) existed to
isolate install.sh's network. The GIT_CONFIG_GLOBAL insteadOf redirect
the windows driver introduced does the same job with a gitconfig file
and works on any OS, so:

* install-e2e-run.yml now runs tests/install/installer-script-e2e.sh
  directly on the bare runner - no sandbox deps, no userns sysctls -
  and takes a runner input;
* the macos matrix calls the SAME workflow on macos-latest, deleting
  install-e2e-macos-run.yml: installer-script -> installer-script /
  hermes-update flip from grey to live, app-update pairs stay TODO
  inside the shared gate;
* install.sh is no longer curl'd through a fake CA - each leg runs
  the copy from the ref a user of that version actually executed;
* scripts/dev-sandbox.sh becomes the minimal isolation sandbox from
  ab6b9492f (separate HERMES_HOME / Electron userData / app name,
  same CLI surface: --persistent, --from, --delete), keeping its
  .hermes-sandbox dir name so gitignore and docs hold;
* nix/sandbox.nix drops the bwrap/proxy closure and keeps only the
  Electron runtime LD_LIBRARY_PATH the desktop app needs.

Verified: nix build .#sandbox + smoke run (isolated HERMES_HOME
created, ephemeral cleanup), shellcheck/bash -n on both scripts,
actionlint on all three workflows, and the new driver ran the full
v0.20.2 -> HEAD hermes-update pass locally before this commit.
2026-08-11 21:20:42 -04:00
Teknium 10e9da6f2d fix: ASCII-only install.ps1 comment; allow-list install_cli's uv PATH fallback
- install.ps1 must stay pure ASCII (PowerShell 5.1 ANSI code-page
  decoding, #66994/#67000): em-dash -> '--'
- tests/test_managed_runtime_resolution.py: install_cli()'s
  shutil.which('uv') is a reviewed fallback AFTER ensure_uv() misses
2026-08-11 17:06:15 -05:00
Teknium baa6b2e34d feat(browser): auto-install the Browser Use CLI instead of silently downgrading
The Browser Use CLI became the default browser backend, but nothing
provisioned it: users without uv/uvx (field report from DongyangHe on
macOS) silently fell back to the built-in browser tools with no notice.

- install_cli() in tools/browser_use_cli.py: uv tool install browser-use
  via the managed uv (bootstrapped on demand), linked into
  $HERMES_HOME/bin (UV_TOOL_BIN_DIR)
- _find_cli() now also probes $HERMES_HOME/bin for browser-use/uvx —
  Hermes' managed uv is not on the user's PATH
- hermes tools post_setup actually installs (Camofox standard) instead
  of printing instructions
- install.sh / install.ps1 provision the CLI at install time
  (best-effort, non-fatal, honors --skip-browser)
- CLI startup shows a one-line notice (24h rate-limited) when the
  default backend downgraded to the built-in tools
2026-08-11 17:06:15 -05:00
ethernet 6d7ba86963 ci(install-e2e): collapse the method vocabulary - 3 install ids, install+2 update ids
Per review the unions were overcomplicated. Install methods are now
just: installer-script (the platform one-liner - curl | bash on
linux/macos, irm | iex on windows), desktop-installer, and
packaged-app (declared, unused). Update methods are every install
method (re-run it over the existing install) plus hermes-update and
app-update. desktop-installer-rerun, desktop-app, curl-bash, and
irm-iex are gone as ids; the windows driver's ValidateSet, switch
arms, and both run workflows' gates renamed to match. tsc --checkJs
clean; generator output re-verified (4 linux / 16 windows / 6 macos
legs for 2 tags).
2026-08-11 17:14:17 -04:00
ethernet 17dea59026 ci(install-e2e): generator drops runtime validation for jsdoc type unions
Per review: the method/version vocabulary is now closed TYPE unions
(@ts-check + jsdoc typedefs - InstallerVersion, InstallMethod,
UpdateMethod - checked with tsc --checkJs, which rejects a SPEC entry
outside the unions; verified by corrupting a copy: 6 errors) instead
of runtime KNOWN_METHODS/ALLOWED_VERSIONS sets. validateEntry and
routeWants are deleted with all the paranoia: the generator always
emits every OS matrix and the dispatch route filter moved to plain
job-level ifs in install-e2e.yml, where the OS jobs already live.
secondUpdate is typed never[] so declaring one is a type error until
a leg implements it. Anything types cannot catch is self-evident on
the next CI run.
2026-08-11 17:11:07 -04:00
ethernet 1c60bdc30d ci(install-e2e): back to the generator - workflows stay generic, names carry everything
Revert the hardcoded 16-job experiment: the combination spec belongs
in scripts/sandbox/generate-e2e-matrix.mjs (restored), not copy-pasted
YAML blocks. What survives from the experiment:

* leg names carry everything - 'os: install -> update (tag -> HEAD)' -
  generated per entry, since slash-joined names are all the graph
  renders;
* pick-releases annotates each tag ({ref, desktop}) and the generator
  threads tag_has_desktop onto windows entries, so the windows run
  workflow still gates pre-desktop tags without a probe job;
* the per-OS run workflows are untouched: single job, static 'e2e'
  name, native skip gates own all capability knowledge.

install-e2e.yml is one generate job + three per-OS matrix fanouts.
Generator shape (4/16/12 legs for 2 tags), annotation threading, and
all six error paths verified; all four workflows pass actionlint;
driver parses clean pure-ASCII.
2026-08-11 16:54:54 -04:00
ethernet 6515f8132a ci(install-e2e): hardcode combination jobs in the primary workflow - one matrix box per combo
GitHub only draws matrix boxes for the PRIMARY workflow's matrices;
everything inside a called workflow flattens into slash-joined names.
The generator + per-tag sub-workflow therefore bought no structure in
the graph and hid the support matrix in a script.

Invert it: install-e2e.yml now declares one job per {os,
install-method -> update-method} combination (2 linux + 8 windows +
6 macos - same 16 the generator produced, verified by inventory
before/after), each a matrix over the picked release tags. The graph
now renders one titled box per combination whose legs read
'... from vX' - the tag axis inside the combo axis. The per-OS run
workflows are unchanged: they own capability knowledge and natively
skip unimplemented method pairs and pre-desktop tags.

install-e2e-tag.yml and generate-e2e-matrix.mjs are deleted; adding a
method is now adding one job block here, implementing one is flipping
the run workflow's gate.
2026-08-11 16:35:03 -04:00
ethernet 6d94678e62 ci(install-e2e): jobs own their skips - generator is pure expansion
Remove all capability knowledge from the combination generator: no
IMPLEMENTED table, no skipped matrix, no per-OS special cases. It now
only declares and expands - every {os, install-method, update-method}
combination is dispatched to its OS's run workflow, and each run
workflow natively skips (grey, job-level if on the method inputs) the
pairs its driver cannot run yet:

* install-e2e-run.yml gains install-method/update-method inputs,
  gates on the supported pairs (curl-bash -> hermes-update/curl-bash),
  and maps the method id to the sandbox script's --route internally;
* install-e2e-macos-run.yml is new - all pairs skip until a macOS
  driver exists, and implementing one flips its job-level if;
* install-e2e-windows-run.yml already worked this way;
* install-e2e-skip.yml is deleted - nothing special-cases macOS
  anymore, so the tag workflow is three identical OS fanouts.

Structure is now uniformly matrix(tag) -> matrix(combination) ->
run-or-skip, with capability knowledge living only next to each
driver. Generator shape/route filters/error paths re-verified; all
five workflows pass actionlint.
2026-08-11 16:17:48 -04:00
ethernet 83d009ae82 ci(install-e2e): nest the graph per starting tag; skips live where the knowledge lives
Two structural changes to the combination fanout:

1. Tags become the OUTER axis, as a sub-graph per starting version:
   install-e2e.yml fans a plain matrix over the picked tags into a new
   per-tag reusable workflow (install-e2e-tag.yml), which runs the
   combination generator for that one tag and fans out one job per
   {os, install-method, update-method}. The Actions graph now reads
   'from vX -> windows: install -> update' per leg. Nothing is
   hardcoded in the workflows: the tag workflow calls the generator
   itself.

2. Native skips move to the point that owns the capability knowledge:
   macOS combos (no driving workflow exists) grey out in the tag
   workflow via install-e2e-skip.yml, untouched by the tag axis; ALL
   windows combos dispatch to install-e2e-windows-run.yml, which takes
   install-method/update-method inputs and natively skips the pairs
   its driver cannot run yet - so implementing a windows method is a
   change in the run workflow + driver only. The driver's -Route ids
   now match the generator's method ids verbatim.

Generator output shape, route filters, and all error paths re-verified
locally; all four workflows pass actionlint; driver re-parses clean
pure-ASCII.
2026-08-11 16:01:36 -04:00
ethernet 1bf0a1c95b ci(install-e2e): generate every install/update combination - one job per combo
Replace the hand-enumerated update/installer/windows-desktop jobs with
a support-matrix generator (scripts/sandbox/generate-e2e-matrix.mjs).
The spec declares every {os, install-method, update-method} combination
a user could be on; generate-matrix expands it and fans out ONE JOB PER
COMBINATION:

* linux combos (curl-bash install x hermes-update/curl-bash rerun)
  drive install-e2e-run.yml, still multiplied by the sampled release
  tags from pick-releases;
* the windows combo (desktop-installer@latest -> desktop-app) drives
  install-e2e-windows-run.yml - the real GUI flow;
* every declared-but-unimplemented combo (all of macOS, the remaining
  windows methods) becomes its own visible skipped job, so the
  coverage gap is enumerable from the Checks tab and implementing one
  is a one-line move into IMPLEMENTED.

Strictness carried into the generator: method ids validate against a
closed set, installer 'versions' arrays only allow 'latest' until a
versioned archive exists, secondUpdate must stay empty until a chained
second-update leg is implemented, and unknown spec keys/routes throw.
Expansion, route filtering, empty-matrix gating, and all seven error
paths verified locally; the dispatch route choice keeps its exact
previous semantics (all/both/update/installer/windows-desktop).
2026-08-11 15:21:24 -04:00
Brooklyn Nicholson 968ec6c6f4 fix(update): manual-result protocol so gated outcomes reach the user
Round 4 of helix4u's review — the durable fallback is now real:

- Result protocol gains `manual`: an ok result the user still must act
  on (reopen the app, reinstall the GUI package, fix the sandbox helper).
  Both orchestrators set it on every DONE_NOTE/downgrade path; the Desktop
  consumer surfaces manual results in a real dialog on next boot instead
  of a log line — the browserless-Linux disappearance now ends at a
  visible dialog, worst case one boot later. Older result files without
  the field parse as manual:false (covered).
- notify ladder verifies EXECUTION, not existence: zenity/kdialog must
  survive their first second (an instant death means no display and falls
  through); the no-surface case is an explicit best-effort contract whose
  guaranteed channel is the result dialog.
- mac DONE_NOTE + failed relaunch of the kept/rolled-back bundle is no
  longer swallowed (`|| true` dropped): the durable message carries both
  facts.
- launch/gate matrices assert `manual` in the result JSON; consumer
  round-trip tested in handoff-result.test.ts.
2026-08-11 11:07:17 -05:00
Brooklyn Nicholson ba28a18b95 fix(update): fail-closed cd, rejected-launch semantics, guaranteed recovery surface
gille's round 3:

- cd into the install root FAILS CLOSED (set -u without set -e let a
  failed cd continue hermes update in the caller's tree -- the exact
  wrong-tree class the correction exists to kill). Honest result, exit 3.
- A supplied mac relaunch target that is missing is a REJECTED launch ->
  manual downgrade; the launch matrix asserts the downgrade instead of
  codifying the old false success. A mac swap-failure DONE_NOTE now still
  relaunches the kept/rolled-back bundle before publishing manual.
- notify_fallback: every rung falls through on EXECUTION failure (a
  notify-send that can't reach D-Bus no longer eats the message), mac
  gets osascript (present on every macOS -- Safari-only machines have no
  chromium shim), and the no-surface terminal case is an explicit logged
  contract: the result file carries the outcome to the next boot.
- update:repro:fresh passes --non-interactive explicitly (prompt_yes_no
  falls back to /dev/tty, so </dev/null was not equivalent).
2026-08-11 11:07:17 -05:00
Brooklyn Nicholson 1dd5c9de83 fix(update): launch acceptance before the terminal event, on both orchestrators
gille's round-2 review: the terminal lifecycle claimed outcomes the
launch hadn't delivered yet.

- posix finish() reorders: outcome -> durable result+marker -> LAUNCH
  WITH ACCEPTANCE -> terminal event. mac acceptance is open's exit code
  (launchd rejects broken bundles loudly); linux verifies the setsid
  child is still alive 1.5s after spawn, so an instant exec failure
  downgrades to a held 'manual' state + truthful result instead of a
  vanished 'done'. Gated skew/manual outcomes publish a real 'manual'
  event (new third shim state -- still zero logic in the page).
- Renderer-free linux recovery: when no chromium-family browser exists,
  manual/error outcomes fire notify-send/zenity/kdialog best-effort so a
  gated non-relaunch is never a silent disappearance.
- windows.ps1 mirrors the contract: Start-DesktopRelaunch returns
  verified acceptance (WMI pid alive / fallback process alive; dying
  before the window appears counts as failure), and the finally block
  downgrades to Show-ManualFinale + rewritten result when the launch
  didn't land. Error path still relaunches after showing itself.
- repro.sh launch / npm run update:repro:launch: real-orchestrator
  matrix for instant-exit relaunch downgrade and skew-message surfacing.
- posix.sh cds into the install root before hermes update (found by the
  sandboxed behind-repro: parts of the update resolve the mutated tree
  from cwd, which is the Desktop's cwd -- it updated the DEVELOPER'S
  checkout while reporting success against the sandbox).
2026-08-11 11:07:17 -05:00
Brooklyn Nicholson f121cd8a06 fix(update): run hermes update from the install root + unbreak fresh repro
The posix orchestrator inherited the Desktop's cwd, and parts of the
update pipeline resolve the tree they mutate from the working directory
-- the sandboxed behind-repro caught it updating the DEVELOPER'S primary
checkout (cwd at spawn time) while reporting success against the
sandbox. cd "$INSTALL_ROOT" before running hermes update, matching the
cwd:updateRoot contract of the deleted in-app path. Verified: rerun
leaves the outside checkout untouched (reflog clean).

repro.sh fresh used a --no-interactive flag install.sh doesn't have;
non-TTY stdin (</dev/null) + --skip-setup is the real non-interactive
contract.
2026-08-11 11:07:17 -05:00
Brooklyn Nicholson bdb4cfd35e fix(update): posix hand-off truth ordering, relaunch-gate port, JSON escaping
Address helix4u's review:

- finish() now delivers the outcome BEFORE publishing it: mac bundle swap
  and the linux relaunch gate run first, then the result file, marker
  removal, and the shim event -- the app launch itself goes last so it
  can't race the result write. A gated/skewed linux install (AppImage/
  deb/rpm, broken sandbox helper) surfaces its message in the result file
  AND holds the shim window open with it instead of closing on a false
  'Opening Hermes...'.
- mac swap is transactional with a checked rollback; a failed install
  restores the previous bundle and the result says so (exit 7 when even
  rollback fails). Failed 'open' rewrites the result truthfully.
- linux gate is an exact port of the deleted update-relaunch.ts logic:
  anchored path-segment match on <root>/apps/desktop/release/linux-unpacked,
  chrome-sandbox absent = namespace build = fine, present = root+setuid
  required, with the real opt-outs (ELECTRON_DISABLE_SANDBOX, --no-sandbox
  among replayed args, or the Desktop vouching) instead of the invented
  HERMES_DESKTOP_NO_SANDBOX. collectRelaunchArgs/sandboxFallbackFromEnv
  live in updater-process.ts again; the Desktop passes filtered launch
  args (after --) and --relaunch-cwd so a deep-link or --no-sandbox
  launch survives the update.
- result/status JSON strings are escaped (git permits '"' in branch
  names) and the result write is atomic (tmp + rename).
- coverage: resolvePosixScriptHandoff + ported helpers in
  updater-process.test.ts (19 pass); repro.sh gate / npm run
  update:repro:gate asserts the whole gate matrix and round-trips a
  hostile branch name through the result JSON.
2026-08-11 11:07:17 -05:00
Brooklyn Nicholson ca5b015e72 test(update): sandboxed repro paths as npm scripts
scripts/desktop-update/repro.sh drives the real code paths against a
disposable HERMES_HOME under /tmp: shim/shim-fail (UI dry runs), fresh
(literal install.sh), behind N (rewound checkout driven forward by the
orchestrator), error (broken venv -> abort + result file). Exposed as
npm run update:shim / update:shim:fail / update:repro:* from
apps/desktop.
2026-08-11 11:07:17 -05:00
Brooklyn Nicholson c991e3f62f feat(update): posix hand-off orchestrator (mac/linux quit-first updates)
scripts/desktop-update/posix.sh is the mac/linux twin of windows.ps1:
the Desktop spawns it detached and QUITS; it waits the app out, runs
plain hermes update (retry-once across the update boundary, truthful
desktop-rebuild completion), swaps/relaunches the .app bundle (mac) or
the release/*-unpacked binary when its sandbox helper is launchable
(linux), writes .hermes-update-result.json, and drives the same shim.
Repo-owned, so every update refreshes the code that drives the next one.

resolvePosixScriptHandoff mirrors the Windows resolver (with the
flat-path fallback covering the scripts/ reorg skew).
2026-08-11 11:07:17 -05:00
Brooklyn Nicholson 503e61b3b1 feat(update): shim UI + event channel for the Windows hand-off
scripts/desktop-update.ps1 moves to scripts/desktop-update/windows.ps1 (a
compat forwarder stays at the old path for one asar/checkout skew cycle)
and gains the shim: scripts/desktop-update/ui.html rendered in a
chromeless Edge app window, fed done|error over a loopback /progress
endpoint. The page is #75895's hand-off screen ported verbatim (Fourier
Flow loader, one title, one line, OS light/dark, charcoal dark seeds);
failure is the terse card pointing at hermes debug share. The WinForms
card stays as the no-Edge fallback, same shape.

Salvaged from the web-shell spike: TcpListener runspace server, Edge
--app spawn with throwaway profile, degradation ladder, -SelfTestUi.

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-08-11 11:07:17 -05:00
Brooklyn Nicholson 854ab7f6df fix(desktop/windows): quiet minimal update hand-off window
The hand-off script's WinForms window was a 720x420 dashboard: streaming
log box, wide marquee, warning label. Updating is a wait, not a dashboard
-- it is now the same shape as the other update surfaces (#75895): a fixed
280x320 panel, marquee loader, one title, one static line, following the
OS light/dark theme (charcoal #232323 seeds, never brand blue).

Failure gets a terse finale instead of a wall of log: 'Failed to update' +
'Run "hermes debug share" in a terminal to send a report' + Close (held
max 5 minutes, then the relaunched Desktop re-surfaces the result banner
as before). The result-json message points at debug share too.

With nothing streamed to the window, the per-line stdout pump is gone:
Invoke-HermesStep drains both pipes async (no deadlock on chatty children,
no frozen marquee on quiet ones) and writes full output to the hand-off
log afterwards, where hermes debug share picks it up.
2026-08-11 11:07:17 -05:00
ethernet 3ee1bb323e fix(ci): resolve fork PRs in the E2E evidence publisher
The publisher read the PR number from the CI run's pull_requests
payload. GitHub keeps that payload empty for fork runs, so the job
printed 'No pull request is associated' and stopped on every fork PR.

Resolve the PR from the run's head owner, branch, and SHA instead.
The SHA match skips runs that a newer push superseded.

A fork PR also has no CI review comment, because the live poller
skips forks. The publisher now logs this and exits clean instead of
raising; the evidence stays in the workflow artifact.
2026-08-11 03:53:52 -04:00
ethernet e3d22b5b29 fix(update): drain uv/pip stderr - undrained pipe deadlocked windows desktop updates
Two consecutive Windows E2E runs hung inside dependency install until
the job timeout: 31449642122 65 minutes in uv pip install .[all],
31453853006 43 minutes in the SQLite-repair uv sync (RUST_LOG=uv=debug
made THAT run hang earlier and with more stderr - the tell).

Root cause: scripts/desktop-update.ps1 redirects both child pipes but
only pumps stdout while the child runs; stderr is ReadToEnd()'d after
exit. uv and pip write progress to stderr. Once that pipe hits the
~64KB buffer, uv blocks on write, hermes update blocks on uv, the
hand-off blocks on hermes update: deadlock. Slower stderr producers
survive by finishing before the buffer fills, which is why the linux
sandbox never sees this.

Fix both sides of the class:
- managed_uv.py candidate sync + main.py _run_install_with_heartbeat:
  merge stderr into stdout (the pipe that IS drained). This arm heals
  EXISTING installs, whose old hand-off script drives the NEW python
  after the git reset.
- desktop-update.ps1: drain stderr concurrently via ReadToEndAsync so
  future bases never block regardless of what a child writes there.

_run_logged_subprocess and _run_npm_install_deterministic already
merge or capture both pipes; the two fixed sites were the only update-
path spawns that redirect stderr without draining it live.
2026-08-11 00:03:47 -04:00
ethernet 8789cf9f0c fix(sec): patch the npm advisories main left open
Main (7537de9e7) moved most of the vulnerable locked versions, but some
fixes live only in the lockfiles and some advisories stayed open. This
commit closes the rest:

website/package.json gets durable overrides for js-yaml 4.3.1,
dompurify 3.4.13, mermaid 11.16.1, and tar 7.5.22. The root workspace
gets the same tar override, which moves the tar 6.2.1 copies under
get-windows and @mapbox/node-pre-gyp past twelve open advisories.
Without an override, a reinstall can pull an old transitive copy back
in.

image-size <=2.0.2 has two infinite-loop DoS advisories and no fixed
release upstream. An override points it at @nous-research/image-size
2.0.3, our maintained fork of the real repo. The OSV scanner resolves
the aliased fork cleanly, so no ignore entries are needed.

The photon sidecar moves @opentelemetry/core to 2.10.0. The
whatsapp-bridge gets a body-parser 1.20.6 override, so the lockfile-only
fix from main cannot regress on reinstall.

website/.npmrc gets matching min-release-age exclusions for the fix
releases that are less than two weeks old.

electron stays at 40.10.2. The 41.x fix for GHSA-9f4c-93c8-jc8g brings
back the install failure that bb8280b75 reverted: install.js in 40.10.3+
extracts with an MSVC native binding, which fails on Windows machines
without the VC++ Redistributable. Upstream tracks this in
electron/electron#52481, with no fix released.
2026-08-10 13:49:37 -04:00
ethernet d5ddd442d7 fix(ci): review comment poller deadlocked on its own run
The poller job set GITHUB_RUN_ID in env: to point at the CI run.
The Actions runner sets the GITHUB_* defaults itself and ignores
the override. Thus the poller read its own run id and watched
itself. Its own run stays in_progress while the poller runs, so
runs_all_completed() was never true. The comment froze at
'waiting for jobs to start' and the job burned its full 3000s
timeout on every PR.

Rename the variable to CI_RUN_ID. Also drop the GITHUB_REPOSITORY
override — it was a no-op for the same reason, and the runner
default already holds the correct value.
2026-08-10 13:48:21 -04:00
ethernet 8359e760be fix(ci): don't report all-good before jobs start
The live comment poller inferred completion from the job list. An empty
job list looks the same as a finished run: GitHub has not spawned the
jobs yet, so nothing is pending, and the poller posted a final
"all good!" comment and exited.

The run status is now the authoritative signal. collect_run_jobs()
returns whether the CI run and every watched sibling run report
status=completed, and the loop exits only when no job is pending AND
all runs are complete. While a run is still queued or in progress with
no visible jobs, the comment shows "waiting for jobs to start" instead
of a final banner.
2026-08-09 22:24:19 -04:00
ethernet 641d254db4 ci: add macos and windows test lanes for the os-marked tests
the markers from the previous commit skip off-host. without a host to
run them on, every marked test is a silent skip. this commit adds the
hosts.

- tests-os.yml runs -m macos_only on macos-latest and -m windows_only
  on windows-latest. ci.yml requires both lanes in all-checks-pass.
- a lane fails on pytest exit code 5 (zero tests selected). a renamed
  marker cannot produce a green job that ran nothing.
- each lane repeats 'not integration' because a command-line -m
  replaces the addopts filter.
- scripts/ci/list_os_marked_tests.py selects which files each lane
  imports. -m filters after collection, and collection imports every
  module. without this helper, one unrelated ImportError on the
  foreign host fails a job whose own tests passed. the helper exits
  non-zero when a marker matches no file, and writes bytes with
  explicit lf so windows crlf translation cannot corrupt the bash
  file list. it has its own tests in tests/ci/.
- the local runner now reports the skipped count and prints a note:
  macos_only/windows_only tests were skipped on this host, and this
  ci lane runs them. a green local run on linux no longer reads as
  coverage of the other hosts.
- the runner default job count is now #cpu, not #cpu*2.
2026-08-09 22:09:49 -04:00
ethernet 7aecab56db ci: move the review comment and the image build out of the CI run
The CI run stayed in progress until its last job ended. Two advisory jobs
set that time: the review-comment poller (40 minutes) and the Docker image
build (45 minutes). Neither job was required to merge.

GitHub refuses `gh run rerun` on a run that is in progress. Thus a reviewer
who added the `ci-reviewed` label had to wait for the two slow jobs, and
label-rerun.yml carried a 2100-second wait loop for this reason. The fast
required jobs were ready long before.

Each slow job now runs in its own workflow:

- docker.yml owns its `pull_request` trigger and does its own change
  detection. The new `detect` job runs the same composite action with the
  same condition that ci.yml applied, so a tests-only PR still skips the
  build. The `workflow_call` trigger is gone.
- ci-review-comment.yml starts on `workflow_run` when CI starts. It reads
  the workflow and the scripts from the default branch, which is the trust
  boundary that the old job got from its `ref: default_branch` checkout.

The poller reads job results through the API, so it can report on a run
that it does not belong to. `WATCH_WORKFLOWS` names sibling workflows for
the same commit, and `select_watched_runs` keeps the newest run for each
name. Thus the comment still shows the Docker results. The list is
newline-separated, because a workflow name can contain a comma.

The poller always exits 0 now. It reports on the CI run from a different
run, so a failed CI job is not a failure of the poller. The CI run has its
own gate for that.

Also correct a parse error in label-rerun.yml. STATUS came from the already
truncated RUN_ID, so its value was the run id and never "completed". Thus
the wait branch always ran.

ci.yml no longer needs `packages: write`, because the image build has left.
2026-08-09 15:40:04 -04:00