NousResearch/hermes-agent

Gateway shutdown, orphan cleanup, and systemd restart issues

Cluster 1617 · apps/desktop. Data as of

3 open items. Fix merged in #14728. Check it covers your case.

The short answer

1open issue
2open PRs
16items grouped
Known?
Yes. 16 related items grouped here, 5 issues already closed.
Fix event
A fix was merged to main in PR #14728. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #14728, #54090. That is separate from the fix event.
Still open
1 issue and 2 PRs.

These items address problems with gateway process shutdown: orphaned child processes blocking systemd restarts, hung or slow graceful shutdown on SIGTERM, and fixes for subprocess cleanup across PTY, TUI, and bridge contexts.

Open issues1

  • Issue#59057Implement Robust Signal Propagation and Graces for PTY Subprocesses in TUI Gateway

Open PRs2

  • PR#37603Fix desktop IME enter handling and gateway restart guards
  • PR#39577fix(gateway): systemd KillMode=mixed + restart drain timeout + cron stop guard (#37454 #37453 #37858)

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #14728 fix(gateway): kill tool subprocesses before adapter disconnect on drain timeout

    merged to main

    files: gateway/run.py, hermes_cli/gateway.py, tests/gateway/test_gateway_shutdown.py, tests/hermes_cli/test_gateway_service.py

    Show the diff
    • gateway/run.py
      @@ -2560,6 +2560,40 @@ async def stop(
                   return
       
               async def _stop_impl() -> None:
      +            def _kill_tool_subprocesses(phase: str) -> None:
      +                """Kill tool subprocesses + tear down terminal envs + browsers.
      +
      +                Called twice in the shutdown path: once eagerly after a
      +                drain timeout forces agent interrupt (so we reclaim bash/
      +                sleep children before systemd TimeoutStopSec escalates to
      +                SIGKILL on the cgroup — #8202), and once as a final
      +                catch-all at the end of _stop_impl() for the graceful
      +                path or anything respawned mid-teardown.
      +
      +                All steps are best-effort; exceptions are swallowed so
      +                one subsystem's failure doesn't block the rest.
      +                """
      +                try:
      +                    from tools.process_registry import process_registry
      +                    _killed = process_registry.kill_all()
      +                    if _killed:
      +                        logger.info(
      +                            "Shutdown (%s): killed %d tool subprocess(es)",
      +                            phase, _killed,
      +                        )
      +                except Exception as _e:
      +                    logger.debug("process_registry.kill_all (%s) error: %s", phase, _e)
      +                try:
      +                    from tools.terminal_tool import cleanup_all_environments
      +                    cleanup_all_environments()
      +                except Exception as _e:
      +                    logger.debug("cleanup_all_environments (%s) error: %s", phase, _e)
      +                try:
      +                    from tools.browser_tool import cleanup_all_browsers
      +                    cleanup_all_browsers()
      +                except Exception as _e:
      +                    logger.debug("cleanup_all_browsers (%s) error: %s", phase, _e)
      +
                   logger.info(
                       "Stopping gateway%s...",
                       " for restart" if self._restart_requested else "",
      @@ -2621,6 +2655,16 @@ async def _stop_impl() -> None:
                           self._update_runtime_status("draining")
                           await asyncio.sleep(0.1)
       
      +                # Kill lingering tool subprocesses NOW, before we spend more
      +                # budget on adapter disconnect / session DB close.  Under
      +                # systemd (TimeoutStopSec bounded by drain_timeout+headroom),
      +                # deferring this to the end of stop() risks systemd escalating
      +                # to SIGKILL on the cgroup first — at which point bash/sleep
      +                # children left behind by an interrupted terminal tool get
      +                # killed by systemd instead of us (issue #8202).  The final
      +                # catch-all cleanup below still runs for the graceful path.
      +                _kill_tool_subprocesses("post-interrupt")
      +
                   if self._restart_requested and self._restart_detached:
                       try:
                           await self._launch_detached_restart_command()
      @@ -2656,22 +2700,13 @@ async def _stop_impl() -> None:
                   self._shutdown_event.set()
       
                   # Global cleanup: kill any remaining tool subprocesses not tied
      -            # to a specific agent (catch-all for zombie prevention).
      -            try:
      -                from tools.process_registry import process_registry
      -                process_registry.kill_all()
      -            except Exception:
      -                pass
      -            try:
      -                from tools.terminal_tool import cleanup_all_environments
      -                cleanup_all_environments()
      -            except Exception:
      -                pass
      -            try:
      -                from tools.browser_tool import cleanup_all_browsers
      -                cleanup_all_browsers()
      -            except Exception:
      -                pass
      +            # to a specific agent (catch-all for zombie prevention). On the
      +            # drain-timeout path we already did this earlier after agent
      +            # interrupt — this second call catches (a) the graceful path
      +            # where drain succeeded without interrupt, and (b) anything
      +            # that got respawned between the earlier call and adapter
      +            # disconnect (defense in depth; safe to call repeatedly).
      +            _kill_tool_subprocesses("final-cleanup")
       
                   # Close SQLite session DBs so the WAL write lock is released.
                   # Without this, --replace and similar restart flows leave the
    • hermes_cli/gateway.py
      @@ -1469,7 +1469,14 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
                   path_entries.append(resolved_node_dir)
       
           common_bin_paths = ["/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin"]
      -    restart_timeout = max(60, int(_get_restart_drain_timeout() or 0))
      +    # systemd's TimeoutStopSec must exceed the gateway's drain_timeout so
      +    # there's budget left for post-interrupt cleanup (tool subprocess kill,
      +    # adapter disconnect, session DB close) before systemd escalates to
      +    # SIGKILL on the cgroup — otherwise bash/sleep tool-call children left
      +    # by a force-interrupted agent get reaped by systemd instead of us
      +    # (#8202). 30s of headroom covers the worst case we've observed.
      +    _drain_timeout = int(_get_restart_drain_timeout() or 0)
      +    restart_timeout = max(60, _drain_timeout) + 30
       
           if system:
               username, group_name, home_dir = _system_service_identity(run_as_user)
    • tests/gateway/test_gateway_shutdown.py
      @@ -145,3 +145,86 @@ async def finish_agents():
           # Start, one count-change update, and final update. Allow one extra update
           # if the loop observes the zero-agent state before exiting.
           assert 3 <= runner._update_runtime_status.call_count <= 4
      +
      +
      [redacted:email]
      +async def test_gateway_stop_kills_tool_subprocesses_before_adapter_disconnect_on_timeout(monkeypatch):
      +    """On drain timeout, tool subprocesses must be killed BEFORE adapter
      +    disconnect so systemd's TimeoutStopSec doesn't SIGKILL the cgroup with
      +    bash/sleep children still attached (#8202)."""
      +    runner, adapter = make_restart_runner()
      +    runner._restart_drain_timeout = 0.01  # force timeout path
      +
      +    call_order: list[str] = []
      +
      +    def _fake_kill_all(task_id=None):
      +        call_order.append("kill_all")
      +        return 2
      +
      +    def _fake_cleanup_envs():
      +        call_order.append("cleanup_environments")
      +
      +    def _fake_cleanup_browsers():
      +        call_order.append("cleanup_browsers")
      +
      +    async def _disconnect():
      +        call_order.append("disconnect")
      +
      +    # Patch the module-level names the stop() helper imports lazily.
      +    import tools.process_registry as _pr
      +    import tools.terminal_tool as _tt
      +    import tools.browser_tool as _bt
      +    monkeypatch.setattr(_pr.process_registry, "kill_all", _fake_kill_all)
      +    monkeypatch.setattr(_tt, "cleanup_all_environments", _fake_cleanup_envs)
      +    monkeypatch.setattr(_bt, "cleanup_all_browsers", _fake_cleanup_browsers)
      +
      +    adapter.disconnect = _disconnect
      +
      +    runner._running_agents = {"session": MagicMock()}
      +
      +    with patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"):
      +        await runner.stop()
      +
      +    # First kill_all must precede the first disconnect.  (Both the eager
      +    # post-interrupt cleanup and the final catch-all call _kill_tool_
      +    # subprocesses, so we expect kill_all to appear twice total.)
      +    assert "kill_all" in call_order
      +    assert "disconnect" in call_order
      +    first_kill = call_order.index("kill_all")
      +    first_disconnect = call_order.index("disconnect")
      +    assert first_kill < first_disconnect, (
      +        f"Tool subprocesses must be killed before adapter disconnect on "
      +        f"drain timeout, got order: {call_order}"
      +    )
      +    # Defense-in-depth final cleanup still runs.
      +    assert call_order.count("kill_all") >= 2
      +
      +
      [redacted:email]
      +async def test_gateway_stop_kills_tool_subprocesses_on_graceful_path(monkeypatch):
      +    """Graceful shutdown (no drain timeout) must still kill tool subprocesses
      +    exactly once via the final catch-all — regression guard against
      +    accidentally removing that call when refactoring."""
      +    runner, adapter = make_restart_runner()
      +    adapter.disconnect = AsyncMock()
      +
      +    kill_count = 0
      +
      +    def _fake_kill_all(task_id=None):
      +        nonlocal kill_count
      +        kill_count += 1
      +        return 0
      +
      +    import tools.process_registry as _pr
      +    import tools.terminal_tool as _tt
      +    import tools.browser_tool as _bt
      +    monkeypatch.setattr(_pr.process_registry, "kill_all", _fake_kill_all)
      +    monkeypatch.setattr(_tt, "cleanup_all_environments", lambda: None)
      +    monkeypatch.setattr(_bt, "cleanup_all_browsers", lambda: None)
      +
      +    # No running agents → drain returns immediately, no timeout, no eager cleanup.
      +    with patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"):
      +        await runner.stop()
      +
      +    # Only the final catch-all fires on the graceful path.
      +    assert kill_count == 1
    • tests/hermes_cli/test_gateway_service.py
      @@ -95,7 +95,10 @@ def test_user_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(self
               assert "ExecStop=" not in unit
               assert "ExecReload=/bin/kill -USR1 $MAINPID" in unit
               assert f"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}" in unit
      -        assert "TimeoutStopSec=60" in unit
      +        # TimeoutStopSec must exceed the default drain_timeout (60s) so
      +        # systemd doesn't SIGKILL the cgroup before post-interrupt cleanup
      +        # (tool subprocess kill, adapter disconnect) runs — issue #8202.
      +        assert "TimeoutStopSec=90" in unit
       
           def test_user_unit_includes_resolved_node_directory_in_path(self, monkeypatch):
               monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: "/home/test/.nvm/versions/node/v24.14.0/bin/node" if cmd == "node" else None)
      @@ -111,7 +114,10 @@ def test_system_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(se
               assert "ExecStop=" not in unit
               assert "ExecReload=/bin/kill -USR1 $MAINPID" in unit
               assert f"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}" in unit
      -        assert "TimeoutStopSec=60" in unit
      +        # TimeoutStopSec must exceed the default drain_timeout (60s) so
      +        # systemd doesn't SIGKILL the cgroup before post-interrupt cleanup
      +        # (tool subprocess kill, adapter disconnect) runs — issue #8202.
      +        assert "TimeoutStopSec=90" in unit
               assert "WantedBy=multi-user.target" in unit
       
       
  • #54090 fix(gateway): reap cgroup orphans via ExecStopPost to unblock systemd restart

    merged to main

    files: gateway/cgroup_cleanup.py, hermes_cli/gateway.py, scripts/release.py, tests/gateway/test_cgroup_cleanup.py, tests/hermes_cli/test_gateway_service.py

    Show the diff
    • gateway/cgroup_cleanup.py
      @@ -0,0 +1,81 @@
      +"""SIGKILL any process left in this systemd unit's cgroup.
      +
      +Runs as ``ExecStopPost=`` so it only fires after the gateway's main process
      +has exited. The gateway already reaps its own tool subprocesses on a clean
      +shutdown; this is the safety net for long-lived helpers it doesn't track
      +(``adb``, platform bridges, etc.) that would otherwise be orphaned in the
      +cgroup and block ``Restart=always`` — issue #37454.
      +
      +We deliberately iterate ``cgroup.procs`` and send per-PID SIGKILLs instead
      +of writing ``1`` to ``cgroup.kill``: the original failure mode in #37454
      +was the kernel returning ``EINVAL`` on the cgroup-wide kill, while per-PID
      +signal delivery uses a separate code path that still works.
      +"""
      +
      +from __future__ import annotations
      +
      +import os
      +import re
      +import signal
      +import sys
      +from pathlib import Path
      +
      +
      +def _own_cgroup_path() -> str | None:
      +    """Return the cgroup v2 path for the calling process, or None."""
      +    try:
      +        text = Path("/proc/self/cgroup").read_text(encoding="utf-8")
      +    except OSError:
      +        return None
      +    match = re.search(r"^0::(.+)$", text, re.MULTILINE)
      +    if not match:
      +        return None
      +    return match.group(1).strip()
      +
      +
      +def _read_cgroup_pids(cgroup_path: str) -> list[int]:
      +    procs_file = Path(f"/sys/fs/cgroup{cgroup_path}/cgroup.procs")
      +    try:
      +        raw = procs_file.read_text(encoding="utf-8")
      +    except OSError:
      +        return []
      +    pids: list[int] = []
      +    for line in raw.splitlines():
      +        line = line.strip()
      +        if not line:
      +            continue
      +        try:
      +            pids.append(int(line))
      +        except ValueError:
      +            continue
      +    return pids
      +
      +
      +def reap_cgroup(cgroup_path: str | None = None) -> int:
      +    """SIGKILL every PID in the cgroup other than the caller. Returns the count killed."""
      +    if cgroup_path is None:
      +        cgroup_path = _own_cgroup_path()
      +    if not cgroup_path:
      +        return 0
      +    own = os.getpid()
      +    killed = 0
      +    for pid in _read_cgroup_pids(cgroup_path):
      +        if pid == own:
      +            continue
      +        try:
      +            os.kill(pid, signal.SIGKILL)  # windows-footgun: ok — Linux-only (reads /proc, /sys/fs/cgroup; runs from a systemd unit)
      +            killed += 1
      +        except ProcessLookupError:
      +            continue
      +        except PermissionError:
      +            continue
      +    return killed
      +
      +
      +def main() -> int:
      +    reap_cgroup()
      +    return 0
      +
      +
      +if __name__ == "__main__":
      +    sys.exit(main())
    • hermes_cli/gateway.py
      @@ -2706,6 +2706,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
       KillMode=mixed
       KillSignal=SIGTERM
       ExecReload=/bin/kill -USR1 $MAINPID
      +ExecStopPost=-{python_path} -m gateway.cgroup_cleanup
       TimeoutStopSec={restart_timeout}
       StandardOutput=journal
       StandardError=journal
      @@ -2739,6 +2740,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
       KillMode=mixed
       KillSignal=SIGTERM
       ExecReload=/bin/kill -USR1 $MAINPID
      +ExecStopPost=-{python_path} -m gateway.cgroup_cleanup
       TimeoutStopSec={restart_timeout}
       StandardOutput=journal
       StandardError=journal
    • scripts/release.py
      @@ -45,6 +45,7 @@
       
       # Auto-extracted from noreply emails + manual overrides
       AUTHOR_MAP = {
      +    "[redacted:email]": "PRATHAMESH75",  # PR #37550 salvage (ExecStopPost cgroup-orphan reaper to unblock systemd restart; #37454)
           "[redacted:email]": "konsisumer",  # PR #19608 salvage (read-modify-write merge in write_credential_pool to preserve concurrently-added credentials; #19566)
           "[redacted:email]": "linyubin",  # PR #50228 salvage (eager fallback on persistent transport timeout/overloaded; #22277)
           "[redacted:email]": "djstunami",  # PR #5316 salvage / co-author (suppress transient check_fn flakes so subagents keep file/terminal tools; #21658 / #5304)
    • tests/gateway/test_cgroup_cleanup.py
      @@ -0,0 +1,100 @@
      +"""Tests for the systemd ExecStopPost cgroup reaper (issue #37454)."""
      +
      +from __future__ import annotations
      +
      +import os
      +import signal
      +from pathlib import Path
      +
      +import pytest
      +
      +from gateway import cgroup_cleanup
      +
      +
      +class TestOwnCgroupPath:
      +    def test_parses_v2_cgroup_path(self, tmp_path, monkeypatch):
      +        proc_self = tmp_path / "cgroup"
      +        proc_self.write_text("0::/user.slice/user-1000.slice/hermes-gateway.service\n")
      +        monkeypatch.setattr(
      +            cgroup_cleanup,
      +            "Path",
      +            lambda p: proc_self if p == "/proc/self/cgroup" else Path(p),
      +        )
      +
      +        assert cgroup_cleanup._own_cgroup_path() == "/user.slice/user-1000.slice/hermes-gateway.service"
      +
      +    def test_returns_none_when_proc_missing(self, monkeypatch):
      +        def _raise(_path):
      +            raise FileNotFoundError
      +
      +        monkeypatch.setattr(cgroup_cleanup.Path, "read_text", lambda self, *a, **k: _raise(self))
      +        assert cgroup_cleanup._own_cgroup_path() is None
      +
      +
      +class TestReapCgroup:
      +    def test_skips_own_pid_and_kills_the_rest(self, tmp_path, monkeypatch):
      +        own = os.getpid()
      +        cgroup_path = "/test.slice/hermes-gateway.service"
      +        procs_file = tmp_path / "cgroup.procs"
      +        procs_file.write_text(f"{own}\n1001\n1002\n\n")
      +
      +        def _fake_path(p):
      +            if p == f"/sys/fs/cgroup{cgroup_path}/cgroup.procs":
      +                return procs_file
      +            return Path(p)
      +
      +        monkeypatch.setattr(cgroup_cleanup, "Path", _fake_path)
      +
      +        killed_pids: list[tuple[int, int]] = []
      +        monkeypatch.setattr(cgroup_cleanup.os, "kill", lambda pid, sig: killed_pids.append((pid, sig)))
      +
      +        count = cgroup_cleanup.reap_cgroup(cgroup_path)
      +
      +        assert count == 2
      +        assert (own, signal.SIGKILL) not in killed_pids
      +        assert (1001, signal.SIGKILL) in killed_pids
      +        assert (1002, signal.SIGKILL) in killed_pids
      +
      +    def test_tolerates_already_exited_pids(self, tmp_path, monkeypatch):
      +        cgroup_path = "/test.slice/hermes-gateway.service"
      +        procs_file = tmp_path / "cgroup.procs"
      +        procs_file.write_text("1001\n1002\n")
      +
      +        monkeypatch.setattr(
      +            cgroup_cleanup,
      +            "Path",
      +            lambda p: procs_file if p.endswith("cgroup.procs") else Path(p),
      +        )
      +
      +        def _kill(pid, _sig):
      +            if pid == 1001:
      +                raise ProcessLookupError
      +            if pid == 1002:
      +                raise PermissionError
      +
      +        monkeypatch.setattr(cgroup_cleanup.os, "kill", _kill)
      +
      +        assert cgroup_cleanup.reap_cgroup(cgroup_path) == 0
      +
      +    def test_noop_when_cgroup_path_unknown(self, monkeypatch):
      +        monkeypatch.setattr(cgroup_cleanup, "_own_cgroup_path", lambda: None)
      +
      +        def _explode(*_a, **_kw):
      +            pytest.fail("os.kill must not be called when cgroup path is unknown")
      +
      +        monkeypatch.setattr(cgroup_cleanup.os, "kill", _explode)
      +        assert cgroup_cleanup.reap_cgroup() == 0
      +
      +    def test_noop_when_procs_file_missing(self, tmp_path, monkeypatch):
      +        cgroup_path = "/missing.slice/hermes-gateway.service"
      +        monkeypatch.setattr(
      +            cgroup_cleanup,
      +            "Path",
      +            lambda p: tmp_path / "does-not-exist" if "cgroup.procs" in p else Path(p),
      +        )
      +
      +        def _explode(*_a, **_kw):
      +            pytest.fail("os.kill must not be called when cgroup.procs is unreadable")
      +
      +        monkeypatch.setattr(cgroup_cleanup.os, "kill", _explode)
      +        assert cgroup_cleanup.reap_cgroup(cgroup_path) == 0
    • tests/hermes_cli/test_gateway_service.py
      @@ -431,6 +431,14 @@ def test_user_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(self
               # systemd doesn't SIGKILL the cgroup before post-interrupt cleanup
               # (tool subprocess kill, adapter disconnect) runs — issue #8202.
               assert self._expected_timeout_stop_sec() in unit
      +        # ExecStopPost reaps any process the gateway didn't clean up itself,
      +        # so long-lived helpers (e.g. adb) can't be left orphaned in the
      +        # cgroup and block Restart=always — issue #37454.
      +        assert "ExecStopPost=" in unit
      +        assert "-m gateway.cgroup_cleanup" in unit
      +        # KillMode=mixed is preserved so the gateway still reaps its own
      +        # tool-call children before systemd SIGKILLs the cgroup — #8202.
      +        assert "KillMode=mixed" in unit
       
           def test_user_unit_includes_resolved_node_directory_in_path(self, monkeypatch):
               monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: "/home/test/.nvm/versions/node/v24.14.0/bin/node" if cmd == "node" else None)
      @@ -493,6 +501,14 @@ def test_system_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(se
               # (tool subprocess kill, adapter disconnect) runs — issue #8202.
               assert self._expected_timeout_stop_sec() in unit
               assert "WantedBy=multi-user.target" in unit
      +        # ExecStopPost reaps any process the gateway didn't clean up itself,
      +        # so long-lived helpers (e.g. adb) can't be left orphaned in the
      +        # cgroup and block Restart=always — issue #37454.
      +        assert "ExecStopPost=" in unit
      +        assert "-m gateway.cgroup_cleanup" in unit
      +        # KillMode=mixed is preserved so the gateway still reaps its own
      +        # tool-call children before systemd SIGKILLs the cgroup — #8202.
      +        assert "KillMode=mixed" in unit
       
       
       class TestGatewayStopCleanup:
  • #99663 fix(gateway): bound SIGTERM post-interrupt grace (salvage of #65084)

    merged to main

    files: cli-config.yaml.example, gateway/restart.py, gateway/run.py, hermes_cli/config_defaults.py, tests/gateway/restart_test_helpers.py, tests/gateway/test_gateway_shutdown.py, tests/gateway/test_restart_drain.py

  • #99675 fix(gateway): run MCP shutdown off-loop with a bounded wait on the SIGTERM path

    merged to main

    files: gateway/run.py, tests/gateway/test_gateway_shutdown.py

4 PRs closed without merging

From the close comments: closed without comment 2 · merged via another change 2

  • #1618 fix: improve systemd gateway service resilience · closed without comment
  • #37550 fix(gateway): reap cgroup orphans via ExecStopPost to unblock restart · merged via another change → #54090
  • #64352 fix(whatsapp): reap bridge process on disconnect · closed without comment
  • #65084 fix(gateway): bound SIGTERM post-interrupt grace · merged via another change → 2213128, #99663, #64155, #82874, #99675
Closed issues · 5
  • Closed#1617Gateway systemd service fails to auto-restart when browser processes orphaned
  • Closed#8202Gateway process not cleaned up on unclean shutdown after provider timeout — bash/sleep children SIGKILL'd by systemd
  • Closed#19153[Feature]: Gateway shutdown can interrupt active session and force restart after SIGTERM/SIGINT`
  • Closed#37454gateway: orphan processes in cgroup block systemd restart for 6+ minutes
  • Closed#64155hermes-gateway: slow/hanging graceful shutdown on SIGTERM causes systemd timeout
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.10: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#14728, #54090, #99663, #99675
Closed issues
5
Attempts
4 PRs closed without merging
Fix link checked
31 Aug 2026

For your agent

The same facts, typed.