Merge branch 'master' into codex/docs-tailscale-serve
This commit is contained in:
301 files changed
+60363
-2610
No files matched your search
@@ -132,6 +132,13 @@
|
||||
# Extra sources for the notes drawer (see docs for the expected format).
|
||||
# HERMES_WEBUI_EXTERNAL_NOTES_SOURCES=
|
||||
|
||||
# Sidebar recency window: how many recent sessions are listed (default: 20).
|
||||
# Also caps how many delegated subagent children can nest at once; raise it
|
||||
# for wide fan-outs. Invalid or non-positive values fall back to 20.
|
||||
# Values above 200 are clamped. Resolved before profile init, so a profile
|
||||
# .env cannot override this server-wide bound.
|
||||
# HERMES_WEBUI_VISIBLE_SESSION_LIMIT=20
|
||||
|
||||
# Directory scanned for WebUI plugins (default: ~/.hermes/plugins).
|
||||
# HERMES_WEBUI_PLUGINS_DIR=~/.hermes/plugins
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ on:
|
||||
- 'api/**.py'
|
||||
- 'server.py'
|
||||
- 'tests/browser_conversation_lifecycle.py'
|
||||
- 'tests/sidebar_terminal_barrier_server.py'
|
||||
- 'tests/browser_historical_transcript_hydration.py'
|
||||
- 'tests/browser_reconnect_scene_redraw.py'
|
||||
- 'requirements*.txt'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/conversation-lifecycle.yml'
|
||||
@@ -23,7 +25,9 @@ on:
|
||||
- 'api/**.py'
|
||||
- 'server.py'
|
||||
- 'tests/browser_conversation_lifecycle.py'
|
||||
- 'tests/sidebar_terminal_barrier_server.py'
|
||||
- 'tests/browser_historical_transcript_hydration.py'
|
||||
- 'tests/browser_reconnect_scene_redraw.py'
|
||||
- 'requirements*.txt'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/conversation-lifecycle.yml'
|
||||
@@ -32,7 +36,8 @@ jobs:
|
||||
live-to-final:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
continue-on-error: true
|
||||
# Keep existing proof rows informational; reconnect regressions fail CI.
|
||||
continue-on-error: ${{ matrix.name != 'reconnect-scene-redraw' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -45,6 +50,9 @@ jobs:
|
||||
scenario: terminal-error
|
||||
- name: historical-transcript-hydration
|
||||
script: tests/browser_historical_transcript_hydration.py
|
||||
- name: reconnect-scene-redraw
|
||||
script: tests/browser_reconnect_scene_redraw.py
|
||||
browsers: chromium
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -69,6 +77,7 @@ jobs:
|
||||
LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts
|
||||
HISTORICAL_HYDRATION_ARTIFACT_DIR: lifecycle-artifacts
|
||||
LIFECYCLE_SCENARIO: ${{ matrix.scenario }}
|
||||
BROWSERS: ${{ matrix.browsers }}
|
||||
run: python ${{ matrix.script }}
|
||||
|
||||
- name: Run settlement frame proof
|
||||
@@ -79,6 +88,14 @@ jobs:
|
||||
LIFECYCLE_TEST_BITE: settle-worklog-frame-proof
|
||||
run: python tests/browser_conversation_lifecycle.py
|
||||
|
||||
- name: Recover a missing terminal frame
|
||||
if: matrix.scenario == 'normal'
|
||||
env:
|
||||
LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts/missing-terminal
|
||||
LIFECYCLE_SCENARIO: normal
|
||||
LIFECYCLE_TEST_BITE: sidebar-idle-missing-terminal
|
||||
run: python tests/browser_conversation_lifecycle.py
|
||||
|
||||
- name: Upload failure artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -5,25 +5,23 @@ repository. Keep it project-specific and safe to publish. Do not put personal
|
||||
machine setup, private network details, credentials, tokens, or local-only
|
||||
workflow notes here.
|
||||
|
||||
## Read first
|
||||
## Documentation by task
|
||||
|
||||
Before making changes, read:
|
||||
|
||||
1. `README.md`
|
||||
2. `CONTRIBUTING.md`
|
||||
3. `docs/CONTRACTS.md`
|
||||
4. `CHANGELOG.md`
|
||||
|
||||
For architecture, testing, or setup work, also read the matching reference:
|
||||
Use the references that match the change; there is no blanket reading list for
|
||||
unrelated work. Keep subsystem-specific safety and contract requirements below.
|
||||
|
||||
- `README.md` for product behavior, setup, usage, and the docs index
|
||||
- `CONTRIBUTING.md` for contribution scope, PR format, and review requirements
|
||||
- `docs/CONTRACTS.md` to find the contract/RFC for the subsystem being changed
|
||||
- `docs/GUIDELINES.md` for bug-class coverage, state ownership, and evidence standards
|
||||
- `CHANGELOG.md` for release-history questions; ordinary PRs do not edit it
|
||||
- `ARCHITECTURE.md` for design constraints and current module layout
|
||||
- `TESTING.md` for local verification commands and manual test guidance
|
||||
- `TESTING.md` for local verification commands and relevant manual checks
|
||||
- `docs/onboarding.md` for first-run onboarding behavior
|
||||
- `docs/troubleshooting.md` for diagnostic flows
|
||||
- `docs/rfcs/README.md` for larger RFCs and state/durability contracts
|
||||
|
||||
For UI or UX work, read `docs/UIUX-GUIDE.md` and `DESIGN.md` before
|
||||
changing layout, interaction flow, themes, chat rendering, or composer chrome.
|
||||
- `docs/UIUX-GUIDE.md` and `DESIGN.md` for layout, interaction flow, themes,
|
||||
chat rendering, and composer chrome
|
||||
|
||||
## Onboarding and reinstall support
|
||||
|
||||
@@ -69,43 +67,71 @@ Follow that checklist's safety rules:
|
||||
the manual verification performed.
|
||||
- For runtime, streaming, recovery, replay, compression, or sidebar metadata
|
||||
changes, name the state layer being mutated and prove the relevant invariant.
|
||||
- Active-run Steer resolves the stream-bound agent with explicit stream and
|
||||
worker ownership before consulting the reusable session cache. Compression
|
||||
may rotate the agent identity; steering must never evict or close an agent.
|
||||
Keep HTTP response writes outside runtime registry locks. A Gateway-owned
|
||||
active run must resolve to the Gateway outcome before any local cache fallback,
|
||||
even when no in-process worker is registered for that stream. Stop publishes
|
||||
cancellation and detaches stream/agent entries under the same stream lock
|
||||
(STREAMS_LOCK -> ACTIVE_RUNS_LOCK); interrupt and session persistence remain
|
||||
outside it. The retained cache-only path (no registered worker) revalidates
|
||||
stream membership, owner, and active-run session/backend/phase and enqueues
|
||||
agent.steer() under that same lock edge, so a Stop that claims cancellation
|
||||
never strands guidance an earlier Steer response reported as accepted. Test
|
||||
both Stop/Steer orderings — registered and cache-only — with deterministic
|
||||
barriers. Initial active-run publication and its cancel flag must share that
|
||||
lock edge with Stop; do not recreate the flag after journal setup. Worker
|
||||
registration must also check its retained cancel event and live stream
|
||||
membership: Stop can remove CANCEL_FLAGS during initialization.
|
||||
Do not re-register a cancelled worker; finalize outside the stream lock.
|
||||
Local Steer accepts only explicit starting/running phases. Close admission
|
||||
by publishing finalizing under STREAMS_LOCK before the last pending-steer
|
||||
drain; earlier accepted guidance is drained, later guidance is rejected with
|
||||
`not_running` while the owned stream is still live (not `stream_dead`). Use
|
||||
one idempotent terminal settlement before done/error/end and final cleanup,
|
||||
covering returned errors, exceptions and self-heal, not only the success path.
|
||||
Merge Agent-returned `pending_steer` with the registered worker's final slot
|
||||
drain and emit leftovers before terminal events, outside registry locks.
|
||||
Test both drain/Steer orderings, including compression-rotated identities.
|
||||
- Inactive-session recovery is separate from live Steer. Resolve durable
|
||||
compression lineage in the session's profile database, read-only, even when
|
||||
the WebUI sidecar has no snapshot flag. Never reopen a sealed parent. Reject
|
||||
stale chat POSTs before workspace/model/pending-state mutation; the browser
|
||||
loads the continuation and preserves the draft without automatic replay.
|
||||
Explicit closures and unknown terminal reasons do not authorize a redirect.
|
||||
- For Docker build changes in `docker_init.bash`, mirror directory exclusions
|
||||
in both the `rsync` and `cp -a` paths — `/opt/hermes` may contain subdirectories
|
||||
with restricted permissions (e.g. `.playwright/`).
|
||||
|
||||
## Before you open a PR — the change guidelines
|
||||
## Completion and verification
|
||||
|
||||
Read [`docs/GUIDELINES.md`](docs/GUIDELINES.md) in full before non-trivial work. It is the
|
||||
distilled set of habits that get a change merged in one review round instead of several. The
|
||||
compressed form:
|
||||
A change is ready for review when:
|
||||
|
||||
1. **Fix the class, not the instance.** A bug usually has siblings — other call sites, backends,
|
||||
companion endpoints, layouts, exit paths. Find them all and fix the shared chokepoint, or name
|
||||
the ones you left out of scope.
|
||||
2. **Trace one authoritative value end-to-end** (`input → normalize → decision → action → persist →
|
||||
cleanup`); the code that *decides* and the code that *acts* must use the same resolved value.
|
||||
3. **When you can't confirm something, fail closed and say so.** Never take the permissive branch on
|
||||
uncertainty; never report a failure as success. "Unknown" is not "allowed."
|
||||
4. **Enumerate the state-space before editing** — entry point, backend, item count (0/1/many), every
|
||||
lifecycle exit (success/error/cancel/replace/teardown), auth on/off, concurrency, hostile input —
|
||||
and cover each or mark it out of scope. Most redo rounds are one un-considered dimension.
|
||||
5. **Assume inputs and check-then-use gaps are adversarial** — validate at the point of use (hold a
|
||||
handle, don't re-resolve a path), scope caches by complete identity, handle crafted input.
|
||||
6. **A test must fail before your fix and pass after it.** Assert observable behavior, not a source
|
||||
string or a mock of the thing under test; use multiple items if selection is what's being tested.
|
||||
7. **Name the owner of every piece of state and prove it's released on every exit** (success, error,
|
||||
cancel, replace, shrink, teardown) — not just the happy path.
|
||||
8. **Fallbacks/defaults are contracts — extend the mechanism, don't copy it.** Editing N parallel
|
||||
blocks identically means you missed a chokepoint (e.g. new copy goes in the `en` locale only).
|
||||
9. **The diff is the task and nothing else.** Extras go in the PR description, not the diff; run the
|
||||
affected + neighboring tests before opening.
|
||||
10. **A visible control costs attention on every visit** — place it by frequency of use and by where
|
||||
mainstream chat apps put the equivalent, not by where your diff already is; verify with
|
||||
before/after images at desktop and narrow widths.
|
||||
- The requested scope is implemented, with related call sites and lifecycle
|
||||
paths covered or explicitly identified as out of scope; unrelated cleanup is absent.
|
||||
- Behavior changes have observable regression evidence where practical, including
|
||||
proof that a bug-fix test fails before the fix and passes after it. Evidence covers
|
||||
affected and neighboring tests, not only new tests.
|
||||
- State changes identify the authoritative value and its owner, use that value
|
||||
through decision, action, and persistence, and account for cleanup on every exit.
|
||||
Fallbacks and defaults extend the existing mechanism rather than duplicate it.
|
||||
- UI changes include before/after evidence at desktop and narrow widths and
|
||||
cover relevant mobile interactions; controls fit their frequency of use.
|
||||
- Relevant docs are current. The PR body follows `CONTRIBUTING.md`, includes
|
||||
`Contract Routing` (and `Contract Change` for intentional contract changes)
|
||||
where required by `docs/CONTRACTS.md`, and reports actual commands, outcomes,
|
||||
assumptions, and anything not verified. Mocks are not proof of external behavior.
|
||||
|
||||
Show the work in the PR body: the siblings you found, proof the test failed before the fix, the
|
||||
verification run, before/after images for visible changes, and an explicit list of what you could
|
||||
not verify.
|
||||
Within the requested scope, you may run local tests with disposable fixtures,
|
||||
fix failures caused by the change, and rerun affected tests without asking for
|
||||
approval at each step. Use `./scripts/test.sh` for pytest as specified above.
|
||||
This permission applies only with confirmed isolated state and no live
|
||||
credentials or services; the runner manages Python dependencies but is not a
|
||||
network sandbox. It does not authorize modifying real state, handling credentials,
|
||||
restarting existing services, or exposing the app beyond localhost. Those actions
|
||||
require explicit human approval and the onboarding safety rules still apply.
|
||||
If verification is blocked, report the blocker rather than claim completion.
|
||||
|
||||
## Local state and secrets
|
||||
|
||||
@@ -113,6 +139,11 @@ Hermes WebUI can read and write real agent state, sessions, workspaces,
|
||||
credentials, and cron data. Treat local validation as potentially destructive
|
||||
unless you have confirmed the active state directories.
|
||||
|
||||
For authority, capability, identity, or containment checks, fail closed when
|
||||
safety cannot be confirmed: unknown is not allowed. Validate adversarial inputs
|
||||
at the point of use, account for check-then-use races, and scope caches by the
|
||||
complete identity so profiles and sessions cannot leak into each other.
|
||||
|
||||
Prefer isolated trial state for experiments:
|
||||
|
||||
```bash
|
||||
|
||||
+139
-3
@@ -56,6 +56,7 @@ actions. The topbar remains focused on conversation context and the workspace/fi
|
||||
.dockerignore Excludes .git, tests/, .env* from Docker builds
|
||||
api/
|
||||
__init__.py Package marker
|
||||
agent_compat.py Resolver for Hermes Agent names moved to sibling modules (compatibility-only)
|
||||
auth.py Optional password authentication, signed cookies, passkeys/WebAuthn
|
||||
config.py Discovery, globals, model detection, reloadable config
|
||||
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve(), security headers
|
||||
@@ -236,6 +237,29 @@ Session is a plain Python class (not a dataclass, not SQLAlchemy):
|
||||
title_from(): takes messages list, finds first user message, returns first 64 chars.
|
||||
Called after run_conversation() completes to set the session title retroactively.
|
||||
|
||||
#### Session transcript reconciliation with `state.db`
|
||||
|
||||
`reconciled_state_db_messages_for_session()` uses
|
||||
`merge_session_messages_append_only()` to combine a WebUI sidecar or context
|
||||
projection with active Agent `state.db` rows. Its explicit
|
||||
`incoming_provenance="state_db"` fence permits a state-only row whose timestamp
|
||||
predates the sidecar tail to use a safe chronological slot. Paginated
|
||||
`msg_limit` consumers rely on this merged order directly rather than applying a
|
||||
later timestamp sort.
|
||||
|
||||
The same merge helper also stitches ordered child sidecars onto archived
|
||||
compression parents. Those calls leave incoming provenance unverified, so their
|
||||
stable message sequence remains append-only even when parent rows carry later,
|
||||
restamped timestamps. Timestamp alone is never authority to move a continuation
|
||||
inside its parent transcript.
|
||||
|
||||
If an older row could only be placed before the first surviving sidecar/context
|
||||
row, the insertion helper declines it to avoid resurrecting compacted history.
|
||||
Reconciliation then appends that row instead of dropping it; rows without a
|
||||
usable timestamp and rows at or after the sidecar tail also append normally.
|
||||
The fallback therefore preserves an accepted state-only row when exact ordering
|
||||
is ambiguous, while safely placeable recovery rows remain chronological.
|
||||
|
||||
#### Imported `state.db` sidebar projection
|
||||
|
||||
`api.models.get_cli_sessions()` projects conversations from the active Hermes
|
||||
@@ -254,6 +278,26 @@ visibility stage decides whether recovered background rows are shown. In
|
||||
are merged; cross-profile scoping, visibility, deduplication, and final route
|
||||
limits remain downstream responsibilities.
|
||||
|
||||
#### Compression lineage and session-list invalidation
|
||||
|
||||
`api.agent_sessions._is_continuation_session()` is the shared classifier for
|
||||
sidebar projection, lineage metadata/reporting, and `state.db` transcript
|
||||
stitching. It uses the direct parent link, no conflicting non-empty source,
|
||||
`compression` or `cli_close` parent end reason, and the existing two-second
|
||||
`started_at` overlap allowance. A `source="tool"` child is always a separate
|
||||
conversation, even when its parent is also a tool session or has no source.
|
||||
A direct `_branched_from`, `_delegate_from`, or `_reset_from` marker in the
|
||||
child's `model_config` also makes a boundary; inherited ancestor markers do
|
||||
not. Malformed or unverifiable marker evidence fails closed as a boundary.
|
||||
The overlap allowance is the existing master policy, not a new window set by
|
||||
this change.
|
||||
|
||||
The gateway watcher's cheap database fingerprint includes `model_config`, so
|
||||
a marker-only update causes a fresh projection. Its published-payload hash
|
||||
covers every emitted session field, not just ID, activity time and message
|
||||
count; a changed projected title or lineage field can therefore emit
|
||||
`sessions_changed` even without message-row churn.
|
||||
|
||||
### 4.3 SSE Streaming Engine
|
||||
|
||||
This is the most architecturally interesting part. Two endpoints cooperate:
|
||||
@@ -418,6 +462,85 @@ whose default `SessionDB()` path remains frozen at module import. Keep this fall
|
||||
compatibility-only: new goal semantics belong in Hermes Agent's native manager rather
|
||||
than a second WebUI implementation.
|
||||
|
||||
### 4.9 Hermes Agent Moved-Name Compatibility
|
||||
|
||||
Hermes Agent owns its module layout. Its September 2026 decomposition moved names the
|
||||
WebUI uses (for example `tools.approval.set_current_session_key` to
|
||||
`tools.approval_context`) into `<stem>_<topic>` sibling modules. The old paths resolve
|
||||
only through temporary PLUGIN-COMPAT `__getattr__` pointers that emit
|
||||
`HermesPluginCompatWarning` and are removed on schedule. The Agent's
|
||||
`compat_manifest.json` is the authoritative map of what moved where.
|
||||
|
||||
WebUI code reaches a moved name only through
|
||||
`api.agent_compat.agent_attr(owner, name, home, default=...)`, which resolves in this
|
||||
order:
|
||||
|
||||
1. the owner module's own namespace: pre-split Agents, and tests that stub the original
|
||||
module in `sys.modules` or patch the name onto it;
|
||||
2. the `home` module: split Agents, with or without the old-path pointer (no warning);
|
||||
3. plain attribute access on the owner: non-module test doubles.
|
||||
|
||||
It raises like the import it replaces (or returns `default`), so each call site keeps its
|
||||
existing fallback. Current users: approval session identity and MCP discovery
|
||||
(`streaming.py`), `/reload-mcp` (`commands.py`), MCP runtime status (`routes.py`), Claude
|
||||
Code credential linking (`oauth.py`), LM Studio reasoning options (`config.py`), and
|
||||
kanban connections and dispatch (`kanban_bridge.py`).
|
||||
|
||||
Import names that are still native to their module directly. Never
|
||||
`from <old module> import <moved name>`, and never feature-detect a moved name with
|
||||
`hasattr`/`getattr` on the old module: once the pointers are removed those silently turn
|
||||
"moved" into "missing" behind the call sites' broad `except` blocks. When a later Agent
|
||||
split moves another name, route it through `agent_attr` and add pre-split and
|
||||
pointer-removed cases to `tests/test_agent_compat.py`. The resolver is
|
||||
compatibility-only: delete it, and import directly from the new homes, once the WebUI
|
||||
stops supporting Agents that predate the split.
|
||||
|
||||
### 4.10 MCP Runtime Profile Boundary
|
||||
|
||||
Hermes Agent owns the in-process MCP ledger. It keys a connection by
|
||||
`(profile_home_key, name)` and registers its tools in that profile's registry overlay
|
||||
only when the calling task serves a *routed* profile: the context-local Hermes-home
|
||||
override differs from the process home. Otherwise the connection uses the bare server
|
||||
name and the global registry slot, which belong to the process profile.
|
||||
|
||||
- `api.profiles._set_hermes_home()` is the single writer of the process-profile home:
|
||||
startup (`init_profile_state()`) and `switch_profile(process_wide=True)` both go
|
||||
through it, so `get_process_profile_home()` and the Agent pin
|
||||
(`hermes_constants.pin_process_hermes_home()`, when the Agent provides it) are updated
|
||||
in the same step as `HERMES_HOME`. Streaming turns still mirror their profile into
|
||||
`os.environ['HERMES_HOME']` for legacy readers; without the pin, that mirror makes a
|
||||
turn's own profile look like the process profile, so same-named servers of different
|
||||
profiles share one bare-name connection.
|
||||
- `/api/mcp/servers`, `/api/mcp/tools`, `/api/notes/sources` and `/reload-mcp` run
|
||||
their Agent calls inside `api.mcp_runtime.mcp_runtime_scope()`, which binds the
|
||||
request profile's home and secret scope (root profile included) without touching
|
||||
`os.environ`. Status, tool count and inventory are read from one scope; the inventory
|
||||
lists only tools in that profile's own registry slot for servers it configures.
|
||||
- A connection *serves* a profile when that profile owns it (`_server_scope_keys`, else
|
||||
the scope in the key) or adopted it (`_server_tool_scopes`, an identical shared
|
||||
connection). `api.mcp_runtime.ledger_key_serves_view()` is the one predicate for
|
||||
status, inventory and the reload summary. The Agent's launch-profile view (scope
|
||||
`None`) is process-wide, so `filter_runtime_status_to_view()` downgrades
|
||||
`get_mcp_status()` rows of servers the root profile does not serve to `configured`
|
||||
rather than showing a routed profile's connection, tool count or connect error.
|
||||
- The scope is trusted only when the Agent's routing decision matches the profile WebUI
|
||||
resolved. When it cannot be confirmed (for example a same-profile turn's mirror on an
|
||||
Agent without the pin), status and inventory withhold runtime data
|
||||
(`runtime_scope: "unavailable"`, shown as a notice in the MCP panel) and
|
||||
`/reload-mcp` refuses instead of resetting another owner's connection.
|
||||
- `/reload-mcp` calls `shutdown_mcp_servers(scope=..., names=...)` with the profile's
|
||||
own scope and the names of its live connections; `scope=None` without `names` is the
|
||||
process-wide wildcard and is used only with Agents that predate profile-scoped MCP
|
||||
(`runtime_scope: "legacy_process"`). A scoped shutdown only clears connect backoff for
|
||||
the live keys it tears down, so WebUI also drops the profile's own cooldown/error
|
||||
entries (`clear_profile_connect_cooldowns()`) before rediscovery: a server that failed
|
||||
to spawn is retried by the reload, as the wildcard did, without touching another
|
||||
owner's backoff.
|
||||
|
||||
Status and inventory stay passive: they never start or probe an MCP server. Ledger key
|
||||
helpers resolve to Hermes Agent's `tools.mcp_tool_scope` when present so the key shape
|
||||
has one owner; the local fallbacks only cover Agents that predate that module.
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend Architecture: Current State
|
||||
@@ -477,7 +600,9 @@ to the active conversation rather than a global app setting.
|
||||
|
||||
Session management:
|
||||
newSession() POST /api/session/new, update S.session, save to localStorage
|
||||
loadSession(sid) GET /api/session?session_id=X, check INFLIGHT first, update S
|
||||
loadSession(sid) GET /api/session?session_id=X (initial load uses the
|
||||
bounded tail `msg_limit=30`; jump-to-start and outline
|
||||
jump pass `msg_limit=all`), check INFLIGHT first, update S
|
||||
deleteSession(sid) POST /api/session/delete, handle active/inactive cases correctly
|
||||
renderSessionList() GET /api/sessions, rebuild #sessionList DOM
|
||||
|
||||
@@ -1345,7 +1470,14 @@ Complete list of all HTTP endpoints as of Sprint 1 (v0.3).
|
||||
/ Returns full HTML app (index page)
|
||||
/index.html Same as /
|
||||
/health {"status":"ok","sessions":N}
|
||||
/api/session ?session_id=X -> full session + messages. 400 if no ID.
|
||||
/api/session ?session_id=X -> session + messages. 400 if no ID.
|
||||
Bare (no msg_limit) keeps the historical full-transcript
|
||||
contract. Recovery paths request a bounded tail
|
||||
(`msg_limit=30`) and restore `_messages_truncated` /
|
||||
`_messages_offset` before persisting anchor-scene
|
||||
metadata. Outline jump and jump-to-start opt in to the
|
||||
full transcript via the explicit `msg_limit=all`
|
||||
escape hatch. See #7310 / #7625 / #7628.
|
||||
/api/sessions List of all session compact() dicts, sorted by updated_at
|
||||
/api/list ?session_id=X&path=. -> directory listing for session workspace
|
||||
/api/file ?session_id=X&path=rel -> file content (text, 200KB limit)
|
||||
@@ -1354,7 +1486,11 @@ Complete list of all HTTP endpoints as of Sprint 1 (v0.3).
|
||||
/api/chat/stream ?stream_id=X -> SSE stream. Long-lived. Emits token/tool/
|
||||
approval/done/error events.
|
||||
/api/chat/stream/status ?stream_id=X -> {"active": true/false, "stream_id": X}
|
||||
/api/approval/pending ?session_id=X -> {"pending": entry_or_null}
|
||||
/api/approval/pending ?session_id=X -> {"pending": entry_or_null}. The approval/clarify
|
||||
fallback pollers stop on a 409 session_profile_mismatch.
|
||||
/api/git-info ?session_id=X -> {"git": status_or_null}. State.db-only sessions
|
||||
(CLI, subagents) use their stored workspace if it resolves via
|
||||
resolve_trusted_workspace; missing/untrusted -> {"git": null}.
|
||||
/api/approval/inject_test ?session_id=X&pattern_key=K&command=C -> test-only endpoint.
|
||||
Injects a pending approval entry into the server process.
|
||||
/api/file/raw ?session_id=X&path=P -> raw file bytes with correct MIME type.
|
||||
|
||||
+481
@@ -3,8 +3,489 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **The settings file can live outside the state directory.** `HERMES_WEBUI_SETTINGS_FILE` points one
|
||||
instance at its own `settings.json`, while sessions, workspaces and projects stay in the state
|
||||
directory. It is read once at startup, so restart after changing it. (#6433 by @futureworld678-create)
|
||||
- **Full-session resolve concurrency is configurable.** `HERMES_WEBUI_MAX_SESSION_RESOLVE` sets how
|
||||
many full-transcript session resolves may run at once (default 2, a positive integer up to 64;
|
||||
zero, negative, non-numeric or out-of-range values fall back to 2). It is process-wide, so a
|
||||
profile's `.env` cannot override it. (#7421, #7656 by @happy5318)
|
||||
- **The sidebar's recent-session window is configurable.** `HERMES_WEBUI_VISIBLE_SESSION_LIMIT` sets
|
||||
how many recent sessions the sidebar lists (default 20). It also bounds how many delegated subagent
|
||||
children can nest at once, so raise it for wide fan-outs. Invalid or non-positive values fall back to
|
||||
20, values above 200 are clamped, and it is resolved before profile init so a profile `.env` cannot
|
||||
override it. (#7631 by @carlotestor)
|
||||
|
||||
### Performance
|
||||
|
||||
- **Reconnect, settle, cancel and undo no longer re-download the whole transcript.** Six recovery
|
||||
paths (offline/bfcache refresh, stream-end settle, cancel sync, `/compress` preflight, `/retry` and
|
||||
`/undo`) sent a bare `GET /api/session` that re-walked, re-redacted and re-serialized every row. The
|
||||
author measured 4–15 s and 28 MB on a 5,003-row session, against 7 ms and 80 KB for the bounded tail.
|
||||
They now request the 30-row tail. The two views that address rows by absolute index (outline jump,
|
||||
jump-to-start) opt into the full transcript with the new `?msg_limit=all`, and session-level
|
||||
`tool_calls` are windowed whenever the returned messages were actually truncated. A bare request
|
||||
keeps its full-transcript contract. (#7310, #7625, #7628 by @happy5318)
|
||||
- **Opening a session while its task is still running is much faster.** Rebuilding the live
|
||||
snapshot from the run journal parsed the journal twice, walked every metering row and grew the
|
||||
reasoning text with repeated string concatenation, which is quadratic on long runs. The journal
|
||||
is now parsed once, metering rows are skipped (their timestamp watermark is kept) and reasoning
|
||||
deltas are joined once per segment. On a 15 MB / 22.7k-event journal the author measured the
|
||||
rebuild going from 4.34 s to 0.49 s with a byte-identical snapshot. The interim-echo check now matches a
|
||||
compact-equivalent suffix without a fixed window, so an echo stretched by interior whitespace is
|
||||
no longer shown twice. (#7310, #7569 by @happy5318)
|
||||
- **Long transcripts with virtualization on stop re-measuring rows in a loop.** When the rendered
|
||||
window switched back and forth between two positions, each switch reset the measurement retry
|
||||
budget, so opt-in transcript virtualization could keep re-measuring rows instead of settling. The
|
||||
budget now resets only when the window reaches a position it hasn't just visited. (#6654, #6717 by
|
||||
@webtecnica)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Gateway-backend turns survive a WebUI restart.** With the Gateway runs API enabled
|
||||
(`HERMES_WEBUI_CHAT_BACKEND=gateway` + `HERMES_WEBUI_GATEWAY_USE_RUNS_API=true`), the Gateway
|
||||
runs the turn, but restarting the WebUI still marked it interrupted, because the Gateway `run_id`
|
||||
only lived in process memory. The run is now saved on the session as soon as the Gateway admits
|
||||
it, with an `Idempotency-Key` so the Gateway keeps a durable record. On startup the WebUI
|
||||
reattaches: it follows `GET /v1/runs/{id}` until the run settles and writes the real final
|
||||
answer back. Text streamed before the restart isn't replayed; the settled answer replaces it.
|
||||
Stop on a reattached turn goes to the Gateway that owns the run. Thanks @carlotestor. (#7785)
|
||||
|
||||
- **Fewer self-inflicted console errors: git badge on CLI/subagent sessions, pollers after a
|
||||
profile switch, and the PWA startup preload.** Three separate sources of noise in DevTools, all
|
||||
caused by the WebUI itself. `/api/git-info` returned 404 for any session without a WebUI sidecar
|
||||
(delegated subagents and CLI/TUI sessions), because it only looked up the sidecar. It now falls
|
||||
back to the session's state.db metadata, and only for a workspace inside the trusted workspace
|
||||
root. When another tab switched the shared profile cookie, an open session's approval and clarify
|
||||
pollers kept re-requesting and getting `409 session_profile_mismatch` every few seconds. They now
|
||||
pause after the first mismatch and re-arm when the tab regains focus or visibility (one retry
|
||||
covers a switch-back that happened mid-request). The `pwa-startup.js` preload is gone: the
|
||||
browser resolved it before the page's `<base href>` was written, so on `/session/<id>` it
|
||||
fetched a wrong URL that was never used. Thanks @carlotestor. (#7789)
|
||||
|
||||
- **CLI sessions you moved into a project stay in that project.** CLI sessions were cut to the
|
||||
recent-session limit before project assignment was checked, so a CLI conversation you'd moved
|
||||
into a project vanished from its project chip once enough newer sessions existed. The
|
||||
assignment was still saved; only the row was gone. The recent limit now applies to unassigned
|
||||
conversations only. Assigned ones stay in the payload (hidden from the main list once the recent
|
||||
window is full) so the project chips can show them. Assigned rows have their own bound
|
||||
(`CLI_PROJECT_ASSIGNED_CAP`, 200 across all projects, shared fairly so one busy project can't
|
||||
take every slot), and a compressed CLI conversation keeps its project through its whole
|
||||
continuation chain. Thanks @rodrigogs. (#6659)
|
||||
|
||||
- **Two open WebUI windows no longer overwrite each other's unread state.** Two clients on the
|
||||
same origin (for example the desktop PWA and a browser tab) share one `localStorage`, but each
|
||||
kept its own in-memory copy of which sessions had been viewed. When one wrote, it replaced the
|
||||
other's newer record, so read chats came back as unread or new completions lost their dot. Each
|
||||
write now merges with what is on disk, keeps the newer record per session, and remembers
|
||||
deletions and cleared completion dots so they don't come back. (#7577 by @snoyberg)
|
||||
- **Switching profiles no longer rebuilds the model list from scratch.** A profile switch used to
|
||||
delete the saved models cache, so the next model-list load re-queried every provider. Each
|
||||
profile now keeps its own cached model list across switches. The cache is still thrown away when
|
||||
that profile's `config.yaml`, `.env` values, or model-provider plugins change, and when the
|
||||
profile is deleted or recreated. (#7632 by @carlotestor)
|
||||
- **Picking a model from a named custom provider sends the right model name.** Choosing a model
|
||||
that belongs to a non-default custom provider (for example `@custom:my-server:model-x`) sent
|
||||
the whole picker id, prefix included, to the provider, which rejected the request. The
|
||||
`@custom:<slug>:` prefix is now stripped before sending and the provider is routed from the
|
||||
slug. Endpoint-style slugs such as `custom:localhost:11434` keep their host and port.
|
||||
(#6895 by @webtecnica, fixes #6884)
|
||||
- **A gateway reset or tool conversation no longer disappears into the parent session.** When a
|
||||
session ended by compression, the sidebar also treated a gateway reset child (stamped
|
||||
`_reset_from`) as its continuation, so that separate conversation vanished from the list and its
|
||||
transcript was stitched into the parent's. Reset and tool children now stay separate, matching
|
||||
Hermes Agent's own continuation rule; a real compression continuation still joins the chain. A
|
||||
lineage marker that can't be read is treated as a boundary, which keeps the row visible rather
|
||||
than merging it. A change to a session's lineage markers alone now also refreshes the sidebar.
|
||||
(#6565 by @ruizanthony)
|
||||
- **A background tab stops polling a session that's gone, and still recovers after a profile
|
||||
switch in another tab.** A hidden tab polling a deleted session used to loop on 404s every six
|
||||
seconds. It now stops after three consecutive 404s but keeps the session as its resume target,
|
||||
so if the 404 came from switching profile in another tab and you switch back, the tab reattaches
|
||||
when you return to it. A queued poll response can no longer stop a replacement poll for the same
|
||||
session. (#7301, @laitekin; completes the #7299 fix)
|
||||
|
||||
- **An image turn's provider context stays out of your message bubble, and Edit/Undo no longer
|
||||
brings a removed image turn back.** When an image turn was mirrored into the Agent's state.db,
|
||||
its rich provider payload could appear in the user bubble, and after Edit or Undo a removed
|
||||
image-turn row could come back in the full, paginated and model-context reads. The bubble now
|
||||
shows what you typed, the payload stays in model context only, and removed rows stay removed,
|
||||
including a same-timestamp duplicate row and a reply that exists only in state.db after an
|
||||
edited checkpoint. (#7754, @starship-s)
|
||||
|
||||
- **MCP status, tool inventory and `/reload-mcp` follow the profile you're using.** With several
|
||||
profiles in one WebUI, a chat turn mirrored its profile into the process environment, so the Agent
|
||||
saw every profile as the launch profile. The MCP panel could then show another profile's servers,
|
||||
and `/reload-mcp` could restart them. Status, tool listing and reload now resolve through the
|
||||
request's profile, and a reload in one profile leaves another profile's live MCP connections and
|
||||
in-flight tool calls alone. This needs the Agent's `pin_process_hermes_home` (hermes-agent #120103).
|
||||
On an older Agent the panel says live status is unavailable while a turn runs, and `/reload-mcp`
|
||||
refuses, rather than guessing. (#7720 by @tancou, fixes #7721)
|
||||
- **On a phone, Enter in the composer inserts a newline.** Some iPhones report a fine pointer to the
|
||||
browser, so the phone-keyboard check fell through and plain Enter sent the message mid-sentence.
|
||||
Phones (iPhone, iPod, and Android phones) now always get a newline on Enter; Ctrl/Cmd+Enter and
|
||||
the Send button still send, and the Send-key setting still wins. Tablets and touch laptops keep
|
||||
the existing check, so an iPad with a Magic Keyboard or an Android tablet with a Bluetooth
|
||||
keyboard still sends on Enter (#3076). The saved Send-key preference is also read before the
|
||||
first keypress, so the first Enter after a slow page load no longer sends. (#6746 by @happy5318)
|
||||
- **Sessions archived in the CLI stay archived in the WebUI.** A cron, webhook, Kanban or CLI
|
||||
session archived from the CLI came back as active in the sidebar whenever it had no WebUI sidecar,
|
||||
because the projection treated a missing sidecar as "not archived". A missing sidecar now means no
|
||||
opinion, so the state.db `archived` flag applies; archiving or unarchiving in the WebUI still wins.
|
||||
In all-profiles mode, a CLI archive in another profile now refreshes the cached session list.
|
||||
(#7548, #7798 by @webtecnica)
|
||||
|
||||
- **Approval and clarify prompts send a browser notification whenever you aren't looking at them.**
|
||||
A card that surfaced through the normal prompt path never produced a notification, and the
|
||||
visibility gate muted cards in a tab that was visible but unfocused. Now every approval or clarify
|
||||
surfacing notifies unless it belongs to the session open in the pane and the tab is both visible
|
||||
and focused. Notifications from other sessions still arrive while you work. When permission is
|
||||
still undecided, the browser is asked at most once per page load, so a pending prompt's 1.5 s
|
||||
re-surfacing no longer repeats the permission request or the "notifications denied" toast. (#7493 by
|
||||
@CharlesMcquade)
|
||||
- **Delegated subagent sessions show inside their parent's project.** With a project selected in the
|
||||
sidebar, subagent sessions disappeared because `state.db` never gives them a `project_id`. Only
|
||||
subagent rows now inherit their parent's project, resolved once per lineage, so forks keep their
|
||||
own "No project" assignment and deep lineages stay fast. (#7765 by @carlotestor)
|
||||
- **Saving a cron job with the model picker at "Default" keeps its provider-only pin.** Editing a job
|
||||
pinned to a provider with no model (the usual shape for a self-hosted OpenAI-compatible router)
|
||||
sent `provider: null` and wiped the pin. The editor now preserves a provider-only pin when the
|
||||
model picker is left at Default. (#7779 by @cushingw)
|
||||
- **A consumed mid-turn `/steer` no longer leaves its out-of-band wrapper in the settled chat (#7600).**
|
||||
A `/steer` reaches the agent as an `[OUT-OF-BAND USER MESSAGE …]` block appended to the turn's
|
||||
last tool result. After the turn settled, that wrapper stayed visible in the chat transcript. The
|
||||
settled-transcript writeback now scrubs the consumed wrapper from the rows it is built from.
|
||||
(#7610 by @webtecnica)
|
||||
- **Saving a very large session no longer reads and parses the whole file just to count messages.**
|
||||
The #1558 backup safeguard in `Session.save()` needs the on-disk message count; it obtained it by
|
||||
loading the entire sidecar. On a real 203 MB / 266,940-message session that made every save
|
||||
expensive. The count is now taken without a full parse, and the shrink-backup behaviour is
|
||||
unchanged. (#7578 by @rodrigogs)
|
||||
- **A cancelled or recovered turn no longer duplicates an answer that was already saved (#6366).**
|
||||
When a completed assistant turn had been persisted and a later cancel/recovery path ran for the
|
||||
same pending turn, recovery could append a duplicate user turn plus a `_partial` clone of the
|
||||
journal. Recovery now stops once the transcript has already advanced past the pending turn.
|
||||
(#7682 by @happy5318)
|
||||
|
||||
- **The live stream reports which model actually served the turn.** A new additive `runtime_model`
|
||||
SSE event carries the model (and provider, when known) that the Agent reported while producing
|
||||
output, separately from the model that was requested. It is journaled for replay, is exposed as
|
||||
`runtime_journal_snapshot.runtime_model`, never falls back to the configured selection when
|
||||
unknown, and never changes the model requested for the next turn. Existing clients ignore it.
|
||||
(#7767 by @ruizanthony)
|
||||
- **Reconnecting to a new turn no longer resumes from the previous turn's replay cursor.** A new
|
||||
turn copies the full transcript into the in-flight state, so the previous turn's assistant reply
|
||||
could seed the replay floor and the reattached stream skipped the current reply's early events.
|
||||
Replay now seeds only from the current live assistant row, and falls back to a full replay that
|
||||
rebuilds the assistant body when a cursor outlives its live state. (#7651 by @happy5318)
|
||||
- **Context-length lookup keeps the configured base URL for ownerless and underscore-named
|
||||
providers.** The #7535 ownership guard also dropped the global `model.base_url` for a `model:`
|
||||
section with a `base_url` but no `provider`, and for provider IDs written with an underscore
|
||||
(`opencode_go` vs `opencode-go`), so those sessions could resolve the wrong context window. Both
|
||||
now keep the URL, while a different declared owner still does not leak its endpoint.
|
||||
(#7743 by @webtecnica)
|
||||
- **A session deleted during a restart no longer produces a spurious recovery warning.** When
|
||||
WebUI startup recovery re-attached background processes, a session that had vanished between
|
||||
enumeration and rebind raised a `KeyError` that was logged as a warning. It now follows the
|
||||
existing skip path, confined to the session lookup, so the vanished owner is skipped, live owners
|
||||
still rebind, and registry errors still warn. (#7753, #7774 by @happy5318)
|
||||
|
||||
- **Waiting on the Agent's session lease is shown as a warning instead of looking stuck.** When
|
||||
another Hermes process (gateway, CLI or cron) holds the session's turn lease, the Agent's
|
||||
"another Hermes process is using this session" notices now reach the chat as a warning status
|
||||
instead of being dropped, and the status clears when the run ends. Classification keys on the Agent
|
||||
status kind (`lifecycle` / `warn`), so user-authored text can never be promoted to a warning.
|
||||
(#7760 by @ruizanthony)
|
||||
- **A stale in-flight projection can no longer reach a gateway watcher after its last subscriber
|
||||
leaves.** Final unsubscribe and queue eviction now invalidate the cache and fence projections that
|
||||
were already in flight, without holding the lock across database reads or SSE writes, so a client
|
||||
that re-subscribes gets a fresh snapshot instead of a stale one. (#7761 by @ruizanthony)
|
||||
- **A burst of "session busy" refusals no longer drops a background-task completion.** When
|
||||
`start_session_turn()` refused a completion wake-up with a transient 409 (Agent runtime stale,
|
||||
process wake-ups paused, or the session busy with another turn), the bridge released the durable
|
||||
claim as a plain failure, so a few refusals in a row could terminally drop a completion whose
|
||||
session was alive and waiting. Transient refusals now return the claim as retryable, while hard
|
||||
failures still use up the attempt budget. (#7758 by @ruizanthony)
|
||||
- **A first visit now uses the browser's language.** The server stores "no preference" as `null`
|
||||
instead of defaulting to `"en"`, so a first-time visitor gets `navigator.language` while an
|
||||
explicitly saved language (including English) still wins, and legacy `settings.json` files that
|
||||
already hold `"en"` keep English. Reading the browser language is guarded, so an environment where
|
||||
`navigator` throws falls back cleanly. (#7622, #7730 by @happy5318)
|
||||
- **The live model list for a custom provider respects its `models:` allowlist, without treating
|
||||
per-model metadata as one.** `/api/models/live` filters a custom provider's live catalog to its
|
||||
configured `models:` when that is a list, or when it is a mapping with `discover_models: false`.
|
||||
A mapping of per-model settings (the shape `hermes setup` writes) keeps the full live catalog, as
|
||||
the Agent does. (#7165 by @happy5318)
|
||||
- **`MEDIA:` links wrapped in inline code no longer 404.** Every `MEDIA:` capture site (renderer,
|
||||
streaming parser, TTS stripper, session-media authorization and snapshot capture, seven in all)
|
||||
swallowed the closing backtick of `` `MEDIA:/path` `` into the path, so the file lookup and the
|
||||
session allowlist both missed. Backtick-wrapped refs are now normalized first, while bare paths that
|
||||
genuinely contain a backtick keep their full name. (#7359, #7708 by @happy5318)
|
||||
- **A turn's Worklog no longer vanishes when the sidebar reports idle before the final frame.**
|
||||
`/api/sessions` could say a session was idle before the chat stream's terminal frame reached the
|
||||
page, and three sidebar paths (idle reconciliation, the INFLIGHT purge and optimistic-row
|
||||
retirement) then erased the pane's Worklog and stream id, so the late `done` was rejected as stale
|
||||
and the settled scene was never saved. The cleanup now waits briefly for the pane's own open
|
||||
stream, then checks `/api/chat/stream/status` once, and falls back to the existing
|
||||
interrupted-stream recovery if that check fails. (#7749 by @franksong2702)
|
||||
- **A session's run journal can no longer be written out of order.** The journal writer reserved a
|
||||
sequence number under the per-path lock, released it, and then appended, so two concurrent writers
|
||||
could land sequence N+1 on disk before N and the replay reader would stop at the gap
|
||||
(`replay_noncontiguous`). Sequence allocation and the physical append now happen under the same
|
||||
existing lock. (#7751 by @franksong2702)
|
||||
- **A stopped chat can no longer publish or reuse its agent after Stop.** A worker that was
|
||||
cancelled could still publish its cached Agent or invoke it after Stop landed, and its late
|
||||
cleanup could close or evict the same Agent object a successor turn had just picked up. The initial
|
||||
path and both credential self-heal paths now take one Stop admission, the cancellation event, live
|
||||
stream and exact Agent are rechecked immediately before invocation (no registry lock is held across
|
||||
provider or tool execution), and cache/lifecycle handles are retired only while the surviving owner
|
||||
still matches. (#7748 by @franksong2702)
|
||||
- **Reconnecting to a running session no longer redraws every tool card over and over.** After a
|
||||
reconnect restored the live activity scene from the run journal, the client also replayed its older
|
||||
cached in-flight tool list on top, so a turn with N tool cards redrew them N times. When the
|
||||
journal-backed scene restores successfully the replay is now skipped (newer rows still arrive
|
||||
through the reattached stream); the replay stays for legacy restores and for a failed or
|
||||
unavailable scene. (#7436, @atchisonbrent)
|
||||
|
||||
- **A settled assistant answer is no longer shown twice (#2051).** Two client-side paths could put a
|
||||
second copy of a finished answer on screen after a turn settled: a stale live-turn node that was
|
||||
still treated as live, and a settled rebuild branch that appended a turn instead of replacing it.
|
||||
The same fix stops an interrupted turn's notice from appearing twice, and stops the composer's
|
||||
model picker from listing one configured custom-provider model twice. The stored transcript was
|
||||
always correct; only the rendering duplicated it. (#7374, @Thireus)
|
||||
|
||||
- **A hidden browser tab stops polling a session that was deleted.** When a tab is in the
|
||||
background, its stream poll kept asking for a session every few seconds after it was deleted or
|
||||
archived away, forever. A `404`/`410` for the polled session now stops that poll; `503` and network
|
||||
errors keep retrying, a late `404` for one session cannot stop another session's poll, and making
|
||||
the tab visible again reopens the live stream. (#7299, #7716 by @happy5318)
|
||||
- **The OpenRouter setup no longer offers a model OpenRouter doesn't serve.** Onboarding offered
|
||||
`z-ai/glm-4.5-flash`, which is not in OpenRouter's catalog, so picking it failed on the first
|
||||
message. That slot now offers `z-ai/glm-4.5-air`, Z.AI's nearest light model. The direct Z.AI
|
||||
setup still offers GLM-4.5 Flash. (#7520, #7734 by @MuhammadUsamaMX)
|
||||
- **Opening the sidebar can no longer stall the agent's writes to `state.db`.** Several WebUI
|
||||
paths that only read the agent's `state.db` could quietly become writers. The read-only opener
|
||||
fell back to a writable connection when `mode=ro` failed. The session listing self-healed a
|
||||
missing `idx_messages_session` with `CREATE INDEX` through its own writable connection, which
|
||||
holds the SQLite writer lock for minutes on a large `messages` table. The cron sidebar, insights
|
||||
and deep-health checks opened the database with a bare `sqlite3.connect()`. Every one of these
|
||||
readers now opens strictly read-only (`file:...?mode=ro`) and never creates an index. A missing
|
||||
index falls back to the existing pre-aggregated listing. The gateway watcher also no longer polls
|
||||
the database when no client is subscribed. Operators with an older agent can create the read
|
||||
indexes in a drained maintenance window with `scripts/ensure_state_db_read_indexes.py`; see
|
||||
`docs/troubleshooting.md`. (#7445 by @ruizanthony)
|
||||
|
||||
- **A failed chat launch no longer leaves the session stuck "running".** If the worker thread
|
||||
couldn't start, its stream ownership and the session's pending-stream fields were never cleared,
|
||||
so the session could look permanently active and the registries grew. Launch failure now clears
|
||||
exactly that stream's records, after releasing the chat-start lock, and never evicts a successor
|
||||
stream. (#6869, #6937 by @jbdrak)
|
||||
- **A compressed conversation no longer shows up twice in the sidebar.** When context
|
||||
compression started the continuation a few milliseconds before the parent was marked ended, the
|
||||
continuation wasn't recognised. The sidebar then showed a duplicate same-title row plus a
|
||||
spurious child-session entry, and opening the conversation didn't stitch the transcript. Both the
|
||||
sidebar and the transcript stitcher now accept a bounded (2 s) early start when every other
|
||||
lineage signal agrees: same source, direct parent link, a compression (or `cli_close`) end reason and not a fork.
|
||||
(#6931, #7021 by @webtecnica)
|
||||
- **The profile switcher no longer 500s in a two-container Docker setup.** With the agent
|
||||
source not mounted (`HERMES_WEBUI_CHAT_BACKEND=gateway`), `GET /api/profiles` fell through to
|
||||
a skills-stats fallback that imported `agent.skill_utils` unguarded, so the missing module
|
||||
surfaced as an error on every profile-list load. The import is now guarded and the skill
|
||||
stats report as unknown in that case, so the picker stays usable (the skills line is simply
|
||||
omitted). Thanks @webtecnica. (#7305, #7312)
|
||||
|
||||
- **A title that mixes Chinese, Japanese or Korean with English terms is no longer rejected.**
|
||||
The cross-script guard that stops a generated title from drifting into the wrong language
|
||||
treated the borrowed Latin words in a CJK title as drift, so a valid title like
|
||||
`WeChat Pay 回调失败排查` or `Python 代码修复` was thrown away and the session kept its
|
||||
fallback title. Latin terms are now accepted when the title also contains CJK text, while
|
||||
an all-Latin title for a CJK conversation is still rejected. Thanks @MuhammadUsamaMX.
|
||||
(#7693, #7727)
|
||||
|
||||
- **A late-arriving prompt no longer renders below the reply it asked for.** When a message
|
||||
reached the transcript from `state.db` after the sidecar had already been merged⟪HERMES-CONTEXT-COMPRESSION: 809 of 1,009 chars omitted here by Hermes's context compressor. This is NOT part of the original tool call and must never be reproduced in new output — always write full, untruncated content.⟫- **A rejected request no longer poisons the next one on the same connection.** `server.py`
|
||||
is a raw HTTP/1.1 handler where `rfile` is the socket itself, so answering a request
|
||||
before reading its body left those bytes queued. The next request on a keep-alive
|
||||
connection was then parsed starting mid-body, and the client got
|
||||
`400 Bad request syntax ('{"a": "b"}GET /api/health HTTP/1.1')` — an error naming a
|
||||
request it never sent, which is expensive to diagnose from the client side. Every
|
||||
reject path now arms `Connection: close` when a body is still pending, and framing is
|
||||
validated strictly (RFC 9110 `1*DIGIT`, duplicate and comma-combined `Content-Length`
|
||||
reconciled, every `Transfer-Encoding` refused since nothing here decodes one). A
|
||||
genuinely bodyless rejection keeps its keep-alive, so healthy pooled connections are
|
||||
not dropped. Thanks @rodrigogs. (#7550, #6658)
|
||||
|
||||
- **Opening a session that belongs to another profile now offers to switch to it instead of
|
||||
looking deleted.** Several cross-profile guards answered `404 Session not found`, which the
|
||||
front-end treats as a missing session and self-heals by clearing the URL and local storage —
|
||||
so a valid deep link into another profile's session destroyed its own way back. Those guards
|
||||
now return the same `409 session_profile_mismatch` the detail-load endpoint has used since
|
||||
#5419, while a genuinely missing session still 404s and still self-heals. The clarify card,
|
||||
compression-recovery card and manual-compression flow were each treating *every* `409` as
|
||||
their own "stale" signal and are now scoped so a cross-profile refusal no longer hides a live
|
||||
prompt or reports a false compression failure. Thanks @happy5318. (#7710, #7714)
|
||||
|
||||
- **An OIDC-only or passkey-only deployment no longer shows a dead password prompt.** The
|
||||
`/login` page rendered the password input, submit button and passkey control
|
||||
unconditionally, so an instance with native OIDC configured and no
|
||||
`HERMES_WEBUI_PASSWORD` displayed a password form that silently 401'd every submit. The
|
||||
controls are now gated on the auth methods actually configured, with the passkey button
|
||||
kept for passwordless-passkey instances where it is the only login affordance. Thanks
|
||||
@happy5318. (#7056, #7715)
|
||||
|
||||
- **A model whose native id contains a slash no longer poisons its configured-model badge.**
|
||||
The badge builder synthesised a `{provider}/{model}` alias alongside the bare id, so a
|
||||
model already carrying a slash (for example `commandcode` + `deepseek/deepseek-v4-flash`)
|
||||
produced `commandcode/deepseek/deepseek-v4-flash` — a non-functional id that leaked into
|
||||
the badge map, persisted into session state and returned `HTTP 400` from the agent. Only
|
||||
the bare id and the `@provider:model` form are emitted now. Thanks @happy5318. (#7290, #7709)
|
||||
|
||||
- **A conversation whose history carries an explicit `null` tool-call list no longer breaks
|
||||
the tool-call summary.** `_extract_tool_calls_from_messages` used `.get('tool_calls', [])`,
|
||||
which returns the default only when the key is *absent* — when a stored message carried the
|
||||
key with a `null` value, the code tried to iterate `None` and raised `TypeError`. Reading
|
||||
such a session now skips the empty entry and still summarises the tool calls that follow it.
|
||||
Thanks @KayZz69. (#7265)
|
||||
|
||||
- **The opencode-go provider lists its real models again instead of a frozen snapshot.** Model
|
||||
discovery had fallen back to a hard-coded catalog, so models added or removed upstream never
|
||||
appeared. The provider now queries live, with the lookup scoped to opencode-go alone: a slow
|
||||
or unreachable endpoint falls back to the static catalog rather than blocking the picker for
|
||||
anyone else, older Agent builds that lack the discovery API keep the previous behaviour, and a
|
||||
configured model allowlist still wins over whatever discovery returns. Thanks
|
||||
@shameez-struggles-to-commit. (#7220)
|
||||
|
||||
- **Denying a gateway approval now retires the request instead of leaving the run waiting.**
|
||||
When an approval routed through the gateway was denied, the deny reached the agent but the
|
||||
local producer was never retired, so the run could sit waiting on a decision that had already
|
||||
been made. Denial now settles the producer atomically, scoped to the exact
|
||||
`(session_id, run_id, approval_id)` that was answered — sibling approvals from the same run
|
||||
and approvals from other runs are left untouched — and repeated or late responses are bounded
|
||||
no-ops rather than resurrecting a settled request. Thanks @snoyberg. (#7570)
|
||||
|
||||
- **The approval card's "Skip all this session" button no longer shows two lightning bolts.**
|
||||
The button rendered its ⚡ twice — once from the icon span in the markup and again from the
|
||||
translated label, which carried its own leading glyph in 14 of 15 locales. The icon now comes
|
||||
only from the markup, matching every sibling button on the card (Allow once / Allow session /
|
||||
Always allow / Deny all pair an icon element with a glyph-free label), and translators no
|
||||
longer carry the symbol in their strings. (#7701)
|
||||
|
||||
- **The gateway watcher no longer polls `state.db` around the clock with nobody listening.**
|
||||
Its poll loop re-fingerprinted the gateway state database every few seconds whether or not
|
||||
any SSE client was attached, and slept in 0.1s increments — roughly 10 wakeups a second, all
|
||||
day, on an idle server. The loop now parks on an event when there are no subscribers and is
|
||||
woken by the first one, so an idle instance does no polling work at all; while subscribed it
|
||||
waits on a single timer that still returns immediately on shutdown. Connecting a client
|
||||
remains prompt — the first subscriber unparks the loop rather than waiting out the poll
|
||||
interval. Thanks @DevNexsler. (#7694)
|
||||
|
||||
- **A long-running turn no longer replays a stale token count after a reload.** The run-journal
|
||||
recorded live metering frames, so reattaching to a stream — or reloading a tab mid-turn —
|
||||
could replay a snapshot from earlier in the same run and briefly show token/TPS figures that
|
||||
had already been superseded. Metering is now live-only and never journaled, and replayed
|
||||
frames no longer carry an event id that could advance the client's resume cursor past real
|
||||
content. Reattach and reload now show the current numbers for the turn in progress. Thanks
|
||||
@laitekin. (#7291)
|
||||
|
||||
- **Steering a conversation works again after the context is compressed.** When compression
|
||||
rotated `agent.session_id`, a steer could no longer find the active worker: it was either
|
||||
silently dropped or accepted and never delivered. Steers now resolve the owning worker
|
||||
directly rather than relying on a cache lookup that compression invalidates, and every
|
||||
terminal exit — normal completion, returned error, raised exception and self-heal — passes
|
||||
through one idempotent settle boundary, so guidance can no longer be stranded between the
|
||||
final drain and teardown. Two related defects are fixed alongside it: **Stop now actually
|
||||
stops** a run that had already passed preflight (previously the worker could consult a
|
||||
removed registry entry, miss the cancellation, and continue to completion), and a live
|
||||
`finalizing` run is no longer reported as `stream_dead`, which had caused the client to tear
|
||||
down its state while the stream was still alive. Compressed sessions from the CLI, TUI,
|
||||
Desktop and ACP now resume correctly, and installations running an older Agent keep their
|
||||
existing sidecar recovery instead of being left unresumable. Thanks @ruizanthony. (#7546)
|
||||
|
||||
- **Slash-command autocomplete stops offering commands the WebUI cannot run.** The composer's
|
||||
`/` menu announced all 51 registered commands, but many are CLI-only — picking one produced
|
||||
a command that went nowhere. The menu now announces only the WebUI-dispatchable subset (16),
|
||||
so the difference shows up where it matters: typing a prefix like `/a` or `/re` no longer
|
||||
fills the list with dead options. Filtering is confined to the suggestion list — manually
|
||||
typing a CLI-only command behaves exactly as before, and plugin commands plus the native
|
||||
`/moa`, `/sessions`, `/resume` and `/pet` remain available. Thanks @webtecnica.
|
||||
|
||||
- **Concurrent background completions stop burning the async-delegation delivery budget.**
|
||||
When several delegated runs finished at once and idle-woke the same session together, each
|
||||
wakeup consumed a delivery attempt before discovering the others, so the budget could be
|
||||
exhausted and later completions went undelivered. Admission is now an atomic per-origin
|
||||
test-and-set: a second wakeup for an origin already in flight defers without claiming, so it
|
||||
costs zero budget and stays eligible through durable restore. The reservation is released on
|
||||
every exit — claim failure, formatting failure, dispatch failure, and turn completion — so a
|
||||
failed wakeup cannot wedge an origin. Thanks @webtecnica.
|
||||
|
||||
- **Images and files produced during Codex commentary are viewable again.** The Agent's OpenAI Codex Responses adapter persists user-visible assistant progress in `codex_message_items` with `phase: "commentary"` while the outer `content` field stays empty. WebUI's session MEDIA authorization and its snapshot capture both read only that outer field, so a `MEDIA:` token emitted during commentary was invisible to both — the artifact was never authorized for the session token and never snapshotted, leaving the user unable to open a file the assistant had just produced. Both consumers now inspect the commentary sidecars as well. The widening is confined to genuinely assistant-authored, correctly-phased items: role, type, phase and content checks all fail closed, so a user-authored message, a tool result or a malformed item cannot grant a path token. Every existing guard is unchanged — exact-path matching, MIME allow-listing, session ownership, profile visibility and the state/secret hard-deny all still run on anything discovered through the new route, and snapshot capture keeps its confinement without double-capturing an artifact already taken from outer content. Verified against the Agent's real producer shape plus an adversarial probe covering user/wrong-role/wrong-phase, denied-state, wrong-path, duplicate, malformed and 300k-item inputs. Thanks @happy5318. (#7654, #7565)
|
||||
|
||||
- **Custom model names containing colons are no longer truncated in the picker.** A model id like `ollamacloud/qwen3.5:397b` was cut at its internal colon, so the picker showed a mangled label and two models that differed only after the colon could render identically. The colon is overloaded here — it separates a `@provider:model` routing lane, and it also appears inside perfectly ordinary model names — so the label builder now takes the catalog as the authority for `@custom` entries and leaves a plain-lane model id whole. Labels are display-only: the original option value survives selection and is dispatched unchanged, so no routing behaviour moves. Catalogued, cold, empty, stale, host:port, leading- and trailing-colon and multi-colon names were each checked to stay non-blank and exception-free. Thanks @webtecnica. (#7401, #7240)
|
||||
|
||||
- **Context-compaction cards stay visible in long transcripts.** When a transcript grew past the virtualization window, the compaction markers explaining where context was compressed scrolled out of the rendered range and vanished, leaving no indication that compaction had occurred. Pre-window markers are now preserved and placed deterministically, and card placement is stable across repeated renders and window changes — verified to neither lose nor duplicate a card as the virtual window moves. Hardened during review: the settled current-summary card was created only when *no* compaction marker was loaded at all, so a transcript carrying older markers plus a newer summary that none of them referenced silently dropped the summary the session was actually operating under — marker presence is not proof that any marker matches. The fallback now keys on whether a loaded marker actually references the current summary, and participates in the single preserved-task-owner selection so the repair cannot duplicate the task card. Mid-compression, empty-summary, unmatched-anchor-key, first-compaction and matched-marker cases were each checked, and reverting the guard reproduces the original suppression. Thanks @ruizanthony. (#7124)
|
||||
|
||||
- **Editing or duplicating a one-shot cron job no longer returns HTTP 500.** The editable schedule field was populated from `schedule_display`, the human-readable label (`once at 2026-08-28 16:00`), and saving submitted that label straight back. The Agent's schedule parser only accepts the canonical timestamp, so it raised — and the route's existing `ValueError` guard did not cover the `update_job()` call, so the failure escaped as a 500. The field now carries the canonical `run_at` for one-shot jobs, and genuinely unparseable input returns a 400 with the parser's message instead of a server error. Natural-language recurring schedules are preserved verbatim: hardening during review found that preferring the canonical cron expression rewrote a job scheduled as `every monday 9am` into `0 9 * * 1` on every edit or duplicate — and because the Agent rebuilds the display string from whatever is submitted, the rewrite stuck and the user silently lost their phrasing. Only the `once at …` label is unparseable, so every other display form is now kept as typed. Thanks @happy5318. (#7649, #7352)
|
||||
|
||||
- **"Webhooks" and "Cron Jobs" projects stop appearing on profiles that have none.** `get_cli_sessions(all_profiles=True)` walks every profile's `state.db` in one request, and when the walk reached a profile holding webhook or cron rows it lazily minted the system project — stamping it with the **UI-selected** profile rather than the profile whose rows triggered the mint. So a webhook project from one profile materialized on whichever profile happened to be open, deleting it from `projects.json` did not help because the next sidebar poll re-minted it, and switching profiles produced another copy. Both mint paths now tag the scanned profile. Single-profile calls keep their previous behavior, and no existing project data is overwritten — a pre-existing mis-tagged entry stays until removed, alongside the correctly tagged one. Thanks @carlotestor. (#7630)
|
||||
|
||||
- **Multi-frame phone photos stop dragging down session responses.** Multi-Picture Format JPEGs — the multi-frame containers phone cameras routinely produce — were being rejected at the first EOI marker, so they missed the native-image exemption and were handed to the full text-secret scan instead. On a real 30-message payload carrying 12 such images that cost 8.4 s of redaction per response; it is now 0.63 s, with every image byte preserved and non-image output identical. The exemption is granted only after the container fully validates: declared extents must tile the file contiguously to EOF, every secondary JPEG must itself be valid, and malformed offsets, bad counts or sizes, either byte order, overlapping or nested extents, gaps, truncation and undeclared trailing bytes all fail closed back to the normal redaction path rather than being exempted. Parser work is bounded by the APP2 segment size and secondary parsing cannot recurse. Verified against 10 hand-built malformed headers and 20,000 deterministic mutations with no exception raised. Thanks @GeorgeCodes19. (#7641)
|
||||
|
||||
- **HTTP access logs survive agent stdout capture.** Agent and tool code replaces or closes `sys.stdout` to capture subprocess output, and the server's per-request access records were being swallowed into that capture instead of reaching the service log — so requests made while a tool was running simply vanished from the log. Access-log records now go through a dedicated writer that owns its own duplicate of the process's original stdout descriptor, taken at import time before any agent code loads, so replacing or closing Python's `sys.stdout` can no longer intercept them. Delivery is proven end to end by a subprocess test that redirects and then closes `sys.stdout` and still observes the records on the real process stdout. Record format, destination and ordering are unchanged for a normal launch, the write is guarded so a broken sink can never break an HTTP response, and `SIGPIPE` is already ignored before serving starts. Thanks @DevNexsler. (#7643, #2684)
|
||||
|
||||
- **The session list stops re-resolving the same profile home once per row.** Building the sidebar's session list called `_resolve_profile_home_param()` for every projected row, and each call did a real filesystem resolve of the same path — a production trace pinned 5-17 s of a slow `/api/session` build to that one call site, reached through `Session.__init__` on every sidecar-metadata cache miss. The resolve is now memoized for the duration of a single session-list build via a call-scoped `ContextVar` entered by `_load_cli_sessions_uncached`, so N rows sharing a profile pay one resolve instead of N. Outside that scope the memo is inert, so nothing else in the process can serve a cached path, and the scope is released on exit including on exception — a value resolved under one profile can never be served to another. The active workspace is hoisted the same way, since it returns the same value for every row in a scan. Thanks @totalitarian. (#7636)
|
||||
|
||||
- **Server-side speech now honors the TTS engine you configured.** `POST /api/tts` only ever consulted the `engine` field on the request body, so any caller that omitted it — an extension, a native client, a script — silently fell back to Edge regardless of the `tts_engine` saved in Settings. A request that omits `engine` (or sends a whitespace-only value) now inherits the persisted engine, resolved before the provider branches run. An explicit request engine still wins, and a persisted value the server cannot use — `browser`, an extension-only engine, an unknown string, a non-string — still collapses to the historical Edge fallback, because browser speech synthesis has no server-side counterpart. The first-party WebUI callers are unchanged: both now send `engine` explicitly, verified by driving the real production request builders and asserting the outgoing body. Thanks @mvanhorn. (#7394, #7391)
|
||||
|
||||
- **Session responses stop redoing the same redaction work on every request.** Redacting a session payload rescanned every string from scratch on each `/api/session` call, including large unchanged transcript bodies that can never contain a secret. Two caches now sit in front of that work: a bounded LRU for large strings and a memoized prefilter that skips values with no candidate pattern, with clean values returned without a copy. Correctness is preserved across runtime policy changes — a plugin calling `register_redaction_patterns()` mid-process invalidates both caches, so a newly registered secret pattern cannot be bypassed by a cached pre-registration result, and the cache is bounded by object size as well as entry count so a single oversized value cannot pin memory. Thanks @ruizanthony. (#7276)
|
||||
|
||||
- **Markdown tables survive a `<br>` in a cell, and spaced asterisks stop turning italic.** Models routinely emit `<br>` inside a table cell to stack values on separate lines. `renderMd()` converted every `<br>` to a newline *before* the table parser ran, so the row split in half: the table rendered with a stub first cell and the remaining rows spilled out underneath as raw pipe text. Separately, the inline emphasis rule matched across spaces, so ordinary prose like `2 * 3 * 4 = 24` rendered as `2 <em> 3 </em> 4`. `<br>` is now converted only outside genuine table blocks — classified with the same grammar the table parser itself uses (a run of pipe-lines whose second line is a separator), so pipe-delimited prose such as `| note<br># heading |` keeps its heading and list rendering — and emphasis no longer matches a leading or trailing space. Hardened during review: the first implementation used a fixed internal placeholder that user text could contain (corrupting prose and breaking link anchors), and its stricter emphasis rule silently dropped `<em> spaced </em>`; both are fixed, with the sanitizer still stripping every attribute from a preserved `<br>`. Thanks @JayaMegelar. (#7618)
|
||||
- **The Extensions settings pane is localized.** Gallery, Installed and Diagnostics were the last large user-facing surface still rendering hardcoded English. Every visible string in the pane now routes through `t()`, with translations supplied across all 15 shipped locales; each referenced key is present in every locale block, so no string can fall back to a raw key name. Extension names and author-supplied descriptions stay as written — they are third-party metadata, not UI chrome. Thanks @Yularzhi. (#7619, #7581)
|
||||
- **`/skills pending` and the other write-approval subcommands reach the agent again.** The WebUI's local `/skills` handler swallowed every argument into its own skill-name search, so the agent-owned write-approval subcommands never reached their handler — `/skills pending`, `/skills approve 3`, `/skills diff` and friends silently ran a name search instead of doing anything. Those subcommands now fall through to the normal send path using the existing opt-out contract that `/reasoning` already uses. Covers every alias the agent handler accepts, including `apply`, `deny` and `drop`, which a first pass missed. Thanks @totalitarian. (#7623)
|
||||
- **Disabled skills are no longer offered by the slash-command picker.** Skills in `skills.disabled` are already excluded from the backend skill-command map, but the WebUI listed every `/api/skills` entry regardless — so a disabled skill stayed selectable in both `/use <skill>` sub-args and the `/`-prefix skill suggestions. Both surfaces now share one filter. Switching profiles is also fenced: an `/api/skills` reply still in flight when the switch lands can no longer commit, so a payload generated for the outgoing profile cannot keep hiding a skill that is enabled in the new one. Separately, a transient `/api/skills` failure no longer wedges the picker — readiness was previously published even when the request rejected, which left an empty cache marked authoritative and stopped the composer from ever retrying until a page reload. Thanks @webtecnica. (#7511, #7509)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **`AGENTS.md` now routes contributors to the references that match their change.** The old "read first" list asked for four files up front regardless of what was being changed, and carried a compressed copy of the ten change guidelines that `docs/GUIDELINES.md` owns. It now maps each reference to the kind of work it applies to and states explicit completion/verification criteria instead. No information is lost — the ten rules remain in `docs/GUIDELINES.md`, which the new version still points to. Thanks @steveafrost. (#7593)
|
||||
- **The `/api/models` cache invalidation contract is documented.** `#7556` shipped a change to the catalog cache's source fingerprint, and its review flagged the surrounding contract as undocumented runtime behavior. `docs/architecture/models-cache-invalidation.md` now records what is cached (in-memory snapshot, per-profile `models_cache.json`, cold vs hot path), the three source axes (`config_yaml` stat identity, `auth_json` content hash with a volatile-key deny-list, baked-in plus Codex catalog hashes) and why each is fingerprinted the way it is, and the invariant that both volatile-key sets are deny-lists that may only remove fields which provably do not gate the provider/model set. Changes no runtime behavior. Thanks @webtecnica. (#7560, #7556)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Two flaky tests are stable again.** Three `get_available_models()` cache-metadata tests raced the 4-second live-rebuild budget on a loaded CI host and could time out onto the stale-cache path. They now pin the budget to `0`, which is the documented setting for the legacy unbounded synchronous rebuild, so they still exercise the real rebuild (#7735). A source-text oracle that string-matched `delete_cli_session`'s source to prove it opens a writable connection is removed. The behavioral test in `test_issue1494_state_db_fd_leak.py` still guards that contract: it fails with `attempt to write a readonly database` if the delete path is ever switched to a read-only connection (#7726). Test-only; no runtime change. Thanks @webtecnica. (#7744, #7742)
|
||||
- **The chat composer grows natively instead of being resized by JavaScript on every keystroke.** `autoResize()` measured `scrollHeight` and wrote `style.height` on each input event — a forced synchronous reflow on the most-typed-in surface in the app. Browsers that support CSS `field-sizing: content` (Chromium today; also Firefox 152 and Safari 26.2) now own the geometry directly, gated on `CSS.supports()`, and the existing JavaScript path is untouched for every other engine. Measured behaviour is identical across both paths: 44px resting height, no jump when the first character is typed or the last deleted, growth to the 200px ceiling, then internal scrolling. Because `field-sizing` deliberately includes placeholder text in content sizing, `:placeholder-shown` pins fixed sizing while the composer is empty so a long placeholder can't inflate it. Thanks @starship-s. (#6760, #5514)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`.zip` artifacts now open from chat instead of silently failing.** A `MEDIA:/path/to/file.zip` link always failed to open: `.zip` was absent from `MIME_MAP`, so archives resolved to `application/octet-stream`, which the session `MEDIA:` token allow-list rejects. `.zip` now maps to `application/zip` and archives are admitted to the session-token type set. Archives are download-only by construction — the new `_ARCHIVE_TYPES` set is never added to either inline-preview set, so a zip always gets `Content-Disposition: attachment` and can never render inline (verified including `inline=1` and an uppercase `.ZIP` suffix). Path confinement is unchanged: a session token still grants only the exact canonical path an assistant-emitted token referenced, and the state/secret deny guard still runs ahead of the allow decision. Thanks @carlotestor. (#7612)
|
||||
- **WebUI no longer depends on Hermes Agent compatibility shims that have already passed their removal date.** The Agent's September 2026 decomposition moved several names WebUI imports (`tools.approval`, `tools.mcp_tool`, `hermes_cli.kanban_db`, …) into sibling modules, leaving the old paths resolvable only through PEP 562 `__getattr__` pointers scheduled for deletion on 2026-09-14. Because every affected call site is wrapped in a broad `except`, losing those pointers would not have crashed anything — features would have degraded *silently*: MCP tools missing from turns, the per-turn approval session key never binding, Claude Code credential linking stopping, and the kanban bridge raising `AttributeError`. A new `api/agent_compat.py::agent_attr()` resolves each moved name from the original module's own namespace first (pre-split Agents and test stubs), then the new home module (split Agents, with or without the pointers), then plain attribute access — so WebUI works across all three Agent shapes and stops emitting `HermesPluginCompatWarning`. Thanks @mikemchargue. (#7574)
|
||||
- **Typing in a long session no longer lags.** The composer's `autoResize()` has a fast path meant to skip an expensive measure-and-restore layout on every keystroke, but the guard enabling it compared the composer height against its CSS `min-height` (44px) rather than the height a single row actually settles at (line-height + padding + borders, ~48px), so the skip never fired in a real browser. Every keystroke then forced a full-document synchronous layout whose cost grows with the rendered transcript — on a ~750-message session this was ~168ms of echo latency per key versus ~72ms with the transcript detached. The fast path now compares against the real one-row height (tracking the appearance font size), so single-row typing skips the layout while any append that genuinely needs a taller composer still triggers a full resize. Thanks @KevinMeinon. (#7604)
|
||||
- **Live-follow no longer breaks mid-stream when content above the tail collapses.** A reader pinned flush at the bottom of the transcript was being unpinned whenever a section *above* the tail collapsed — a worklog "Done" fold, a thinking/tool card collapse, an interim-note collapse. The collapse shrinks the transcript height, the browser clamps the scroll position downward and fires a scroll event, and that was misread as the user scrolling away, killing live-follow while a response was still streaming. The scroll listener now only treats an upward scroll as intent when the reader has actually left the bottom, so an above-tail collapse keeps following the stream. Genuine upward scrolls still unpin immediately. Thanks @KevinMeinon. (#7605)
|
||||
- **Sidebar source-filter tabs are now localized.** The WebUI / CLI session-source tabs in the sidebar were hardcoded English (`WebUI sessions (N)` / `CLI sessions (N)`) and assigned straight to `textContent`, so they stayed English under every non-English locale. They now route through `t()` with the standard `{0}` count placeholder and are translated across all shipped locales. Thanks @Yularzhi. (#7602, #7580)
|
||||
- **Logging in with password auth enabled no longer lands on a 404.** After a successful login the server redirects to `/sessions`, but `/sessions` (plural) was missing from the SPA-shell route allowlist in `handle_get()` — only `/`, `/index.html`, and `/session/*` (singular) served the app shell — so an authenticated user was met with a `404 not found` immediately after logging in. `/sessions` (and `/sessions?query`) now serves the app shell like the other SPA entry points. Authentication is unchanged: `check_auth()` still runs before the route handler, so an unauthenticated `GET /sessions` still redirects to the login page with `next=/sessions`. The bug was invisible without auth because the SPA handles `/sessions` client-side and the server route was never hit. Thanks @Skawsk59. (#7598)
|
||||
|
||||
- **The WebUI fails closed when the Agent's Git identity disappears, instead of risking a run on mixed module state.** `ensure_agent_runtime_current()` guards against executing a mix of old and new Python modules across an Agent update by comparing the on-disk Git revision to the one loaded in memory. When that revision became unreadable — the file deleted, permission-denied, empty, corrupt, the source directory removed, or the read itself raising — a missing revision is indistinguishable from a changed one and must not be treated as proof the loaded code is current. The guard now raises the typed stale-runtime error for every unreadable-identity case (surfacing as an HTTP 409, not a 500), with no forgeable file-metadata (`mtime`/size) fallback authorizing execution. `KeyboardInterrupt`/`SystemExit` still propagate, the matching-revision happy path is unchanged, and initially non-Git runtimes are unaffected. Thanks @LeonardoLGDS. (#7407)
|
||||
|
||||
- **Opening a session with one oversized metadata field no longer re-parses the entire sidecar.** #5854 kept `anchor_activity_scenes` from overflowing the 64 KB metadata prefix and cached a legacy sidecar's authoritative facts so an unchanged one is fully parsed at most once — but a *different* oversized field still pushed the prefix past the fast-read window, forcing a full parse of the whole file on every open. The prefix is now read in staged geometric reads, so an oversized field is recovered from the prefix instead of triggering a whole-file parse; a field beyond 1 MiB still falls back to the full parse rather than risking a truncated read. Legacy-sidecar caching remains gated on the file being legacy (its key includes `ctime_ns`, so a same-size rewrite still invalidates), modern sidecars are unaffected, and message/scene projections are byte-identical to before for normal, oversized-summary, oversized-scenes, and both-oversized sidecars. Thanks @rodrigogs. (#7573)
|
||||
|
||||
- **Models picked from a second custom provider group route to that provider's own endpoint instead of failing with a 400.** A model selected from a custom provider registered only under `custom_providers:` (picker value like `@custom:omni:antigravity/gemini-3.7-flash-tiered`) was resolved against the active provider's credentials, so it returned `HTTP 400 Invalid model format or no credentials for provider: <bare-model>`. Provider-qualified `@custom:<slug>` ids now resolve to the owning provider's own `base_url` and credentials. A `providers.<slug>` record with no endpoint fails closed with a terminal `CUSTOM_ROUTE_NO_ENDPOINT` (clearing any foreign connection bundle before construction or cache publication) rather than leaking another provider's endpoint, and cross-provider credential/base-URL isolation plus per-profile agent-cache signatures are preserved. Thanks @b3nw. (#7319)
|
||||
|
||||
- **Structured (multimodal) message content stored in `state.db` renders as its real content instead of a placeholder, without dropping or misattributing turns.** When the session projection decodes the `state.db` content sentinel, list-valued content now gets an out-of-band identity — a `("structured_content", canonical)` tuple that no scalar string can compare equal to — shared across the merge, dedup, content, and visible reconciliation keys, so an in-band string can't collide with a rich row and an image-only row can no longer vanish from the projection. The expensive canonical serialisation is memoised per content object for the duration of a single `merge_session_messages_append_only()` call (scoped through a `ContextVar`, keyed by object identity with the object held alive so an id can't be reused for a different list mid-call), restoring one serialisation per list per call. Empty/falsy content keys exactly as before. Thanks @totalitarian. (#7492)
|
||||
|
||||
- **An empty composer no longer inflates to fit its own placeholder on the JavaScript sizing path.** The fallback resizer measured `scrollHeight` for an empty textarea, which reflects the *placeholder* — so a long busy or compression hint (which wraps to two or three lines) grew the empty composer to roughly 71px, and only after a resize that happened while empty, making the resting height depend on history rather than content. The inline height is now cleared when the value is empty so CSS `min-height` defines the resting size, matching the native path. (#6760)
|
||||
|
||||
- **Delegated subagent sessions nest under the conversation that spawned them instead of landing at the top of the sidebar.** The sidebar forces a *cross-surface* child row to top level when its parent row belongs to another surface (a Telegram/messaging parent), so an independent continuation doesn't get stacked under a foreign thread. A delegated subagent is also technically cross-source relative to its WebUI parent, so it was swept up by that rule and surfaced as a bare top-level row with no indication of what spawned it. Delegated subagents are now exempt from that branch and attach to their visible parent, while independent cross-surface continuations still stay top level. The delegated role is resolved in strict precedence order from the first non-blank of `raw_source` → `source_tag` → `source` and additionally requires the backend's child-session flag, so a stale lower-priority field can't promote an independent row into a delegated one. Thanks @lowlandsheperd. (#7263)
|
||||
|
||||
- **Remote (SSH/Docker) workspaces keep their target-side POSIX paths across session restore, streaming, uploads and project context.** When the terminal backend runs off-host, a workspace path such as `/srv/remote-alice` belongs to the *target* machine — but several resolvers normalized it against the WebUI host's filesystem, so a host-side expansion (macOS firmlinks turning it into `/System/Volumes/Data/srv/remote-alice`, or a local `~` expansion) could be persisted back onto the session and then used as the per-turn runtime CWD. The workspace, streaming, upload and project-context resolvers now receive the owning **session's profile** explicitly instead of resolving against whatever profile happens to be ambient, so remote paths stay target-side and a session owned by one profile can never resolve through another's. Local (non-remote) workspaces normalize exactly as before, and symlink/`..`/outside-root rejection is unchanged. Thanks @alfred-rootson. (#7168, closes #7131)
|
||||
|
||||
- **Signal gateway conversations are classified as messaging sessions instead of falling into the default/other bucket.** The messaging-source allowlists never learned about Signal, so a session started on the Signal gateway was normalized to `other`, failed the client-side external-session check the session list uses to render and refresh gateway sessions, and never showed up in the sidebar. `signal` (labeled "Signal") is now in the messaging-source sets on both sides — the Python normalizer (`MESSAGING_SOURCES` / `SOURCE_LABELS`), which also feeds the server-side messaging allowlist, and the client-side classifier (`_MESSAGING_RAW_SOURCES` / `_MESSAGING_SOURCE_LABELS`) — so Signal sessions group, label, and list exactly like Telegram, Discord, Slack, WeCom, and Matrix. No other source's classification changes. Thanks @webtecnica. (#7571)
|
||||
|
||||
- **Opening a session no longer stalls just because Codex refreshed its model cache in the background.** The WebUI keys its 24-hour `/api/models` cache partly on Codex's `~/.codex/models_cache.json`, but that file was fingerprinted by `mtime` + size — and Codex rewrites it on its own refresh timer, bumping those stat fields even when the model catalog is byte-identical (only a volatile `fetched_at` timestamp changed). Every Codex refresh therefore invalidated the cache, and the next session open paid a full live rebuild whose serial provider probes stalled the open (#7540). The Codex cache is now fingerprinted by its *content* with the refresh-timestamp fields (`fetched_at`, `updated_at`) stripped, so a timestamp-only rewrite keeps the cache while any genuine catalog change (models, `etag`, `client_version`) still invalidates it. A malformed or pathologically deep cache file degrades safely to the old stat fingerprint rather than erroring. Thanks @webtecnica. (#7556, closes #7540)
|
||||
|
||||
- **A timed-out command-approval card can be dismissed again instead of getting stuck on screen forever.** When the agent raised a local approval with no gateway notifier registered, the raw pending entry was stored without an `approval_id`; the frontend needs that id to own the card, so a card that timed out (or otherwise went unanswered) could be neither approved nor dismissed and the poll re-served it indefinitely. Reconciliation now mints a stable `gwlocal-mirrorless:` id on the raw local entry itself — the same tokenization contract already used for gateway producers — so the id survives repeated polls and the client's dismiss marker sticks. Gateway/run-bearing entries are untouched, and the synthetic prefix can't be mistaken for gateway authority (routing keys on exact ids, `run_id`, and mirror tokens, never the prefix). Thanks @CharlesMcquade. (#7510)
|
||||
|
||||
- **Manual "Regenerate title" now works for conversations that open with several queued user messages.** For a session whose transcript begins with consecutive user turns (no assistant reply before the second user message), the first-exchange walker stopped at the second user row with no assistant text, so "Regenerate title" skipped the LLM call, silently persisted the local fallback (a truncated first message), and returned `200` with that same wrong title on every retry — leaving the session permanently stuck. Manual regeneration now scans past the opening user rows to the first complete user+assistant pair so it reaches the aux model. The automatic in-stream title path is deliberately unchanged. Thanks @Raylands. (#7545, closes #7543)
|
||||
|
||||
- **Custom providers that Hermes auto-discovered now show their full live `/v1/models` catalog again instead of just the saved models.** When a provider entry carries `models_discovered: true` alongside a `models:` mapping of per-model metadata (vision, context length), that mapping is discovery metadata — not a hand-curated allowlist — but the WebUI treated it as one and suppressed the live catalog, collapsing the model picker to only the already-saved IDs. Such a provider now defers to its live `/v1/models` catalog (matching the Hermes Agent's own `_models_config_is_allowlist` semantics), while an explicit `discover_models: false` opt-out (including the Agent-compatible string forms `false`/`no`/`0`) still pins the configured list, and a transient empty live probe falls back to the configured models rather than dropping the provider from the picker. Thanks @evgenyponomarev. (#7409, closes #7404)
|
||||
|
||||
- **Ctrl-C and `ctl.sh`-launched daemons now shut down gracefully instead of leaving the server running.** The WebUI installed a SIGTERM handler that drains in-flight work through `serve_forever()`'s `finally`, but SIGINT fell through to Python's default, so a daemon started via `./ctl.sh start` could only be stopped with `kill <pid>` and the Settings "Stop server" button did not stop it. SIGINT now shares the same idempotent graceful-shutdown handler as SIGTERM. Thanks @aniruddhaadak80. (#7315, closes #7078)
|
||||
|
||||
@@ -220,7 +220,7 @@ If an AI assistant is helping with install, reinstall, bootstrap, provider setup
|
||||
- Session actions via `⋯` dropdown per session — pin, move to project, archive, duplicate, delete
|
||||
- Pin/star sessions to the top of the sidebar (gold indicator)
|
||||
- Archive sessions (hide without deleting, toggle to show)
|
||||
- Session projects -- named groups with colors for organizing sessions
|
||||
- Session projects -- named groups with colors for organizing sessions; delegated subagent sessions have no project of their own and follow their nearest ancestor's project in the project filter and the Unassigned chip; forks and other child sessions keep their own project, so a fork moved to "No project" stays Unassigned
|
||||
- Session tags -- add #tag to titles for colored chips and click-to-filter
|
||||
- Grouped by Today / Yesterday / Earlier in the sidebar (collapsible date groups)
|
||||
- Download as Markdown transcript, full JSON export, or import from JSON
|
||||
@@ -285,12 +285,14 @@ If an AI assistant is helping with install, reinstall, bootstrap, provider setup
|
||||
### Settings and configuration
|
||||
- **Hermes Control Center** (sidebar launcher button) -- Conversation tab (export/import/clear), Preferences tab (model, send key, theme, language, all toggles), System tab (version, password)
|
||||
- Send key: Enter (default) or Ctrl/Cmd+Enter
|
||||
- Send-key on touch devices: plain Enter inserts a newline on phones (iPhone/iPod, Android phones) and on tablets / iPadOS / touch-capable Macs that only expose a coarse pointer (no attached hardware keyboard), matching the software keyboard's return key. Devices that report a fine pointer (for example, a tablet with a hardware keyboard) keep the configured physical-keyboard send-key behavior. The configured shortcut and the Send button remain available for sending.
|
||||
- Show/hide CLI sessions toggle (enabled by default)
|
||||
- Token usage display toggle (off by default, also via `/usage` command)
|
||||
- Control Center always opens on the Conversation tab; resets on close
|
||||
- Unsaved changes guard -- discard/save prompt when closing with unpersisted changes
|
||||
- Cron completion alerts -- toast notifications and unread badges scoped to the active profile on the Tasks tab and session sidebar
|
||||
- Background agent error alerts -- banner when a non-active session encounters an error
|
||||
- Approval and clarification browser alerts notify once per pending owner when its session is not actively viewed. Local approval dismissal hides attention without resolving the prompt; resolution, replacement, and cancellation retire that owner's alert state. Denied or failed notification delivery can retry while the prompt remains pending.
|
||||
|
||||
### Slash commands
|
||||
- Type `/` in the composer for autocomplete dropdown
|
||||
@@ -361,6 +363,7 @@ Full list of environment variables:
|
||||
| `HERMES_WEBUI_HOST` | `127.0.0.1` | Bind address (`0.0.0.0` for all IPv4, `::` for all IPv6, `::1` for IPv6 loopback) |
|
||||
| `HERMES_WEBUI_PORT` | `8787` | Port |
|
||||
| `HERMES_WEBUI_STATE_DIR` | `$HERMES_HOME/webui` (Windows default `%LOCALAPPDATA%\hermes\webui`, POSIX default `~/.hermes/webui`) | Where sessions and state are stored. **Note (upgrade):** the default now follows `HERMES_HOME` — if you previously relocated `HERMES_HOME` to a non-default base **without** setting `HERMES_WEBUI_STATE_DIR`, your WebUI state now resolves to `$HERMES_HOME/webui` instead of the old platform-default `~/.hermes/webui`. To keep using the old location, set `HERMES_WEBUI_STATE_DIR` to it (or move the directory). Installs with `HERMES_HOME` unset or at the default base are unaffected. |
|
||||
| `HERMES_WEBUI_SETTINGS_FILE` | `<state dir>/settings.json` | Optional path for this instance's WebUI settings file (sessions, workspaces and projects stay in the state directory). Read once at startup, so restart after changing it |
|
||||
| `HERMES_WEBUI_DEFAULT_WORKSPACE` | `~/workspace` | Default workspace |
|
||||
| `HERMES_WEBUI_DEFAULT_MODEL` | *(provider default)* | Optional model override; leave unset to use the active Hermes provider default |
|
||||
| `HERMES_WEBUI_PASSWORD` | *(unset)* | Set to enable password authentication |
|
||||
@@ -373,6 +376,7 @@ Full list of environment variables:
|
||||
| `HERMES_HOME` | Windows: `%LOCALAPPDATA%\hermes`; POSIX: `~/.hermes` | Base directory for Hermes state (affects all paths) |
|
||||
| `HERMES_CONFIG_PATH` | `$HERMES_HOME/config.yaml` | Path to Hermes config file |
|
||||
| `HERMES_WEBUI_SERVER_CWD` | *(unset)* | Working directory for the server process. Defaults to the agent dir; point it at a writable workspace when the agent dir is read-only so fallback relative writes land somewhere writable |
|
||||
| `HERMES_WEBUI_VISIBLE_SESSION_LIMIT` | `20` | Size of the sidebar's interactive recency window (how many recent non-cron/webhook sessions are listed). Also bounds how many delegated subagent children can nest at once, since a child only renders when its row wins a slot in the window — raise it for wide fan-outs. Non-integer or non-positive values fall back to the default. Values above 200 are clamped. Resolved before profile init, so a profile `.env` cannot override it |
|
||||
| `HERMES_WEBUI_AGENT_CACHE_MAX` | `25` | Max live agent instances kept warm in the in-memory LRU. Each pins a full conversation transcript, so this is the dominant lever on resident memory — lower it on installs with many long sessions to cap RAM (at the cost of more cold reloads) |
|
||||
| `HERMES_WEBUI_SESSIONS_MAX` | `100` | Legacy operator override for the max compact `Session` objects held in the in-memory LRU. Prefer the `webui.sessions_cache_max` key in `config.yaml` (which takes precedence); this env var remains a fallback. Bounds resident memory so long-running installs cannot accumulate every session ever touched and eventually crash (#4765/#2233/#4633). Eviction only ever drops clean, persisted, non-active sessions; an evicted session lazily reloads from its JSON sidecar on next access |
|
||||
|
||||
|
||||
+47
-6
@@ -36,6 +36,24 @@ npm run lint:runtime
|
||||
npx eslint --no-config-lookup -c eslint.runtime-guard.config.mjs "static/**/*.js"
|
||||
```
|
||||
|
||||
## Native raster redaction boundary
|
||||
|
||||
Run `./scripts/test.sh tests/test_mpf_jpeg_redaction.py tests/test_raster_data_uri_redaction.py tests/test_security_redaction.py`.
|
||||
Native `messages[*].content[*].image_url.url` raster data may bypass text
|
||||
credential scanning only after complete container validation. MPF JPEGs require
|
||||
an APP2 MP Index with bounded TIFF entries, contiguous declared JPEG extents,
|
||||
and a complete SOF/scan/EOI sequence in each image. Gaps, overlaps, malformed
|
||||
frames, and trailing bytes fall back to text redaction. Image-shaped tool
|
||||
metadata does not acquire this exemption. This is structural validation, not
|
||||
pixel decoding or secret detection inside image metadata/pixels.
|
||||
|
||||
`tests/fixtures/multipicture.jpg` contains two Pillow-generated 8x8 solid-color
|
||||
frames (red and blue), not private photographs; generation instructions live in
|
||||
`tests/test_mpf_jpeg_redaction.py`. The tests cover both TIFF byte orders and
|
||||
credential text appended after the final EOI. Helper timing improvements do
|
||||
not by themselves prove browser or shared-server responsiveness; verify those
|
||||
separately with a real conversation and concurrent requests.
|
||||
|
||||
## Python lint gate (ruff) — forward-looking, new-code-only
|
||||
|
||||
The Python twin of the ESLint runtime guard. A curated `ruff` ruleset
|
||||
@@ -136,12 +154,35 @@ HISTORICAL_HYDRATION_TEST_BITE=break-tool-link \
|
||||
python tests/browser_historical_transcript_hydration.py
|
||||
```
|
||||
|
||||
The dedicated `Conversation lifecycle (informational)` workflow runs the current
|
||||
proof rows (`normal`, `terminal-error`, and `historical-transcript-hydration`) and
|
||||
stays non-blocking while the public
|
||||
matrix expands to additional behavior rows. The maintainer's private QA harness
|
||||
remains broader; later public slices will add session switching, reconnect/replay,
|
||||
cancellation, compression, and recovery.
|
||||
The dedicated `Conversation lifecycle (informational)` workflow keeps the existing
|
||||
proof rows (`normal`, `terminal-error`, and `historical-transcript-hydration`)
|
||||
non-blocking while the public matrix expands. The Chromium
|
||||
`reconnect-scene-redraw` row does **not** allow failures: it exercises the real
|
||||
`loadSession` reconnect path and fails the workflow on a regression. Required
|
||||
merge checks remain a maintainer-controlled repository setting.
|
||||
The maintainer's private QA harness remains broader; later public slices will
|
||||
add cancellation, compression, and recovery coverage.
|
||||
|
||||
### Active-session reconnect redraw gate
|
||||
|
||||
`tests/browser_reconnect_scene_redraw.py` is an opt-in deterministic browser
|
||||
gate for reconnecting to a running session with a large Anchor activity scene.
|
||||
It uses an isolated temporary server/home and fixture SSE events—no agent,
|
||||
provider credentials, or production state. By default it checks Chromium and
|
||||
WebKit, desktop and 390px viewports, and all three activity display modes:
|
||||
|
||||
```bash
|
||||
pip install playwright
|
||||
python -m playwright install chromium webkit
|
||||
python tests/browser_reconnect_scene_redraw.py
|
||||
```
|
||||
|
||||
`TOOL_COUNT`, `BROWSERS`, and `MODES` narrow the matrix. `MEASURE_BASELINE=1`
|
||||
records an unoptimized baseline without enforcing the redraw ceiling;
|
||||
`METRICS_FILE` saves JSON metrics and `SCREENSHOT_DIR` saves generated-fixture
|
||||
screenshots. This gate tests page reconstruction, journal-cursor resume, and
|
||||
subsequent fixture SSE updates. It does not test a real provider/network or PWA
|
||||
service-worker cache behavior.
|
||||
|
||||
### Streaming reader intent
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Resolve Hermes Agent names that moved to a sibling module.
|
||||
|
||||
Hermes Agent's September 2026 decomposition moved many names out of their
|
||||
original modules (``tools.approval``, ``tools.mcp_tool``,
|
||||
``hermes_cli.kanban_db``, ...) into ``<stem>_<topic>`` siblings. The old paths
|
||||
kept resolving for a while only through PEP 562 ``__getattr__`` pointers that
|
||||
emit ``HermesPluginCompatWarning`` and are removed on schedule, so WebUI code
|
||||
must not rely on them. Importing only the new module would instead break Agent
|
||||
installs that predate the split.
|
||||
|
||||
``agent_attr`` is compatibility-only and resolves a moved name in this order:
|
||||
|
||||
1. the original module's own namespace -- pre-split Agents, and tests that stub
|
||||
the original module in ``sys.modules`` or patch the name onto it;
|
||||
2. the new home module -- split Agents, with or without the old-path pointers;
|
||||
3. plain attribute access on the original object -- non-module test doubles,
|
||||
or an Agent whose new home is not importable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def agent_attr(owner: Any, name: str, home: str, default: Any = _MISSING) -> Any:
|
||||
"""Return ``owner.name`` without going through a removed/deprecated pointer.
|
||||
|
||||
``owner`` is the original module (or its dotted name); ``home`` is the
|
||||
dotted name of the module the Agent moved ``name`` to. Raises
|
||||
``ImportError``/``AttributeError`` like the plain import it replaces, unless
|
||||
``default`` is given.
|
||||
"""
|
||||
if isinstance(owner, str):
|
||||
try:
|
||||
owner = importlib.import_module(owner)
|
||||
except ImportError:
|
||||
if default is _MISSING:
|
||||
return getattr(importlib.import_module(home), name)
|
||||
try:
|
||||
return getattr(importlib.import_module(home), name, default)
|
||||
except ImportError:
|
||||
return default
|
||||
if isinstance(owner, ModuleType) and name not in vars(owner):
|
||||
try:
|
||||
return getattr(importlib.import_module(home), name)
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
if default is _MISSING:
|
||||
return getattr(owner, name)
|
||||
return getattr(owner, name, default)
|
||||
+22
-11
@@ -397,18 +397,29 @@ def ensure_agent_runtime_current() -> None:
|
||||
"""Reject a known Git checkout change instead of mixing Python modules."""
|
||||
if _AGENT_REVISION is None:
|
||||
return
|
||||
if (
|
||||
_read_agent_revision(_AGENT_SOURCE_DIR, module_path=_AGENT_MODULE_PATH)
|
||||
!= _AGENT_REVISION
|
||||
):
|
||||
# Automatic restart needs an Agent-owned success receipt bound to this
|
||||
# transaction, final revision and healthy environment, plus an atomic
|
||||
# handoff excluding mutations across replacement. Marker polling and a
|
||||
# final revision read supply neither contract. Keep this path manual.
|
||||
raise AgentRuntimeChangedError(
|
||||
_RESTART_REQUIRED_MESSAGE,
|
||||
agent_update_state=_agent_update_transaction_state(),
|
||||
fresh_revision = None
|
||||
try:
|
||||
fresh_revision = _read_agent_revision(
|
||||
_AGENT_SOURCE_DIR, module_path=_AGENT_MODULE_PATH
|
||||
)
|
||||
except Exception:
|
||||
# An unreadable revision is indistinguishable from a changed one, so a
|
||||
# failed read must fail CLOSED like every other identity-loss shape
|
||||
# (deleted, permission-denied, empty, corrupt, removed directory).
|
||||
# Letting the raw exception escape would surface as an HTTP 500 instead
|
||||
# of the typed stale-runtime response the barrier is meant to produce.
|
||||
fresh_revision = None
|
||||
if fresh_revision == _AGENT_REVISION:
|
||||
return
|
||||
|
||||
# Automatic restart needs an Agent-owned success receipt bound to this
|
||||
# transaction, final revision and healthy environment, plus an atomic
|
||||
# handoff excluding mutations across replacement. Marker polling and a
|
||||
# final revision read supply neither contract. Keep this path manual.
|
||||
raise AgentRuntimeChangedError(
|
||||
_RESTART_REQUIRED_MESSAGE,
|
||||
agent_update_state=_agent_update_transaction_state(),
|
||||
)
|
||||
|
||||
|
||||
def require_ai_agent_class():
|
||||
|
||||
+499
-126
@@ -1,8 +1,12 @@
|
||||
"""Shared helpers for reading Hermes Agent sessions from state.db."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from urllib.parse import quote, quote_from_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,36 +22,71 @@ logger = logging.getLogger(__name__)
|
||||
_SOURCE_COLUMN_WARNED_DB_PATHS: set[str] = set()
|
||||
|
||||
|
||||
def state_db_file_uri(db_path, platform: str | None = None) -> str:
|
||||
"""Build the ``file:`` URI (no query string) for an absolute ``state.db`` path.
|
||||
|
||||
``Path.as_uri()`` is not usable here: for a UNC share it emits
|
||||
``file://server/share/state.db`` and SQLite rejects any non-local URI
|
||||
authority unless compiled with ``SQLITE_ALLOW_URI_AUTHORITY`` (the
|
||||
CPython 3.11-3.13 Windows builds are not). SQLite instead accepts the
|
||||
*empty*-authority spelling ``file:////server/share/state.db``, whose path
|
||||
``//server/share/...`` the Windows VFS opens as the UNC name. Drive-letter
|
||||
paths keep the documented ``file:///C:/...`` form and POSIX paths are
|
||||
unchanged. Only ``/`` survives unescaped, so ``?`` and ``#`` in path
|
||||
components cannot leak into the query string.
|
||||
|
||||
``platform`` defaults to the running interpreter; tests pass it explicitly
|
||||
so the Windows shapes are checked from any host.
|
||||
"""
|
||||
platform = platform or sys.platform
|
||||
if platform == "win32":
|
||||
win = PureWindowsPath(str(db_path))
|
||||
drive = win.drive
|
||||
if drive.startswith("\\\\?\\"):
|
||||
# Extended-length prefix: "\\?\C:" or "\\?\UNC\server\share".
|
||||
drive = drive[4:]
|
||||
if drive.upper().startswith("UNC\\"):
|
||||
drive = "\\\\" + drive[4:]
|
||||
parts = win.parts[1:] # drop the anchor, keep the path components
|
||||
if drive.startswith("\\\\"):
|
||||
host_share = drive[2:].replace("\\", "/")
|
||||
posix_path = "//" + "/".join((host_share, *parts))
|
||||
else:
|
||||
posix_path = "/" + "/".join((drive, *parts))
|
||||
# ``:`` stays literal so the drive letter keeps SQLite's documented
|
||||
# ``file:///C:/...`` shape (``Path.as_uri()`` leaves it unescaped too).
|
||||
return "file://" + quote(posix_path, safe="/:")
|
||||
posix_path = PurePosixPath(str(db_path)).as_posix()
|
||||
# Quote the filesystem BYTES, not the str: a POSIX path component that is
|
||||
# not valid UTF-8 is carried in the str as ``surrogateescape`` code points,
|
||||
# which ``quote(str)`` rejects with UnicodeEncodeError (the caller then
|
||||
# treats the db as unreadable and every agent-backed session vanishes).
|
||||
# ``Path.as_uri()`` — what master used — percent-encodes os.fsencode()
|
||||
# bytes; this keeps that behavior.
|
||||
return "file://" + quote_from_bytes(os.fsencode(posix_path), safe="/")
|
||||
|
||||
|
||||
def state_db_readonly_uri(db_path, platform: str | None = None) -> str:
|
||||
"""Strict read-only (``mode=ro``) form of :func:`state_db_file_uri`."""
|
||||
return state_db_file_uri(db_path, platform=platform) + "?mode=ro"
|
||||
|
||||
|
||||
def open_state_db_readonly(db_path: Path, log: logging.Logger | None = None) -> sqlite3.Connection:
|
||||
"""Open the live agent ``state.db`` read-only for a pure-read projection.
|
||||
|
||||
Same rationale as the session-listing path (#5455): a write-capable handle
|
||||
on the multi-GB, WAL ``state.db`` while the agent streams into it adds
|
||||
needless checkpoint/lock surface. The read-only ``file:...?mode=ro`` URI
|
||||
avoids that. Falls back to a writable connection (and warns) if the
|
||||
read-only open fails, so callers never lose data on exotic filesystems.
|
||||
avoids that. Read failures propagate; a reader never upgrades to a writer.
|
||||
|
||||
The caller must ensure ``db_path`` exists — this raises ``FileNotFoundError``
|
||||
for a missing path rather than letting the writable fallback below create an
|
||||
empty, writable ``state.db`` there (a ghost DB in the agent's HOME). The
|
||||
fallback is only for an *existing* DB whose read-only open fails on an exotic
|
||||
filesystem, so a real read never loses data.
|
||||
for a missing path rather than creating a ghost database.
|
||||
|
||||
Callers own the returned connection (wrap it in ``contextlib.closing``).
|
||||
"""
|
||||
log = log or logger
|
||||
if not db_path.exists():
|
||||
raise FileNotFoundError(f"agent state.db not found: {db_path}")
|
||||
read_only_uri = f"{db_path.resolve().as_uri()}?mode=ro"
|
||||
try:
|
||||
return sqlite3.connect(read_only_uri, uri=True)
|
||||
except sqlite3.Error as exc:
|
||||
log.warning(
|
||||
"agent state.db read-only open failed for %s; falling back to writable connection: %s",
|
||||
db_path,
|
||||
exc,
|
||||
)
|
||||
return sqlite3.connect(str(db_path))
|
||||
return sqlite3.connect(state_db_readonly_uri(db_path.resolve()), uri=True)
|
||||
|
||||
|
||||
MESSAGING_SOURCES = {
|
||||
@@ -59,11 +98,31 @@ MESSAGING_SOURCES = {
|
||||
'telegram',
|
||||
'weixin',
|
||||
'matrix',
|
||||
'signal',
|
||||
}
|
||||
|
||||
CLI_MIN_UNTITLED_MESSAGE_COUNT = 6
|
||||
CLI_MIN_UNTITLED_USER_MESSAGE_COUNT = 2
|
||||
|
||||
# Sub-second scheduling/write-order races during a compression/cli_close
|
||||
# handoff can persist the continuation row with ``started_at`` a few
|
||||
# milliseconds BEFORE the parent's ``ended_at`` lands (#6931). The tolerance
|
||||
# below lets those rows still classify as the next segment; the fork /
|
||||
# model_config branch-marker / cross-source / end_reason guards in
|
||||
# ``_is_continuation_session`` already rule out unrelated rows, so a small
|
||||
# window cannot collapse genuinely concurrently started children.
|
||||
CONTINUATION_STARTED_AT_TOLERANCE_SECONDS = 2.0
|
||||
|
||||
# Accepted ``project_assignment`` values for read_importable_agent_session_rows.
|
||||
PROJECT_ASSIGNMENT_FILTERS = frozenset({'assigned', 'unassigned'})
|
||||
|
||||
# Raw-row oversample factors for the bounded candidate window. ``limit`` counts
|
||||
# LOGICAL conversations, but compression segments and the post-projection
|
||||
# visibility filters are spent from the raw window first, so a single 8x pass can
|
||||
# come up short on a lineage-heavy profile. The second factor is only paid when
|
||||
# the first window was fully consumed AND still under-delivered.
|
||||
CANDIDATE_WINDOW_MULTIPLIERS = (8, 32)
|
||||
|
||||
SOURCE_LABELS = {
|
||||
'acp': 'ACP',
|
||||
'api_server': 'API',
|
||||
@@ -82,6 +141,7 @@ SOURCE_LABELS = {
|
||||
'webui': 'WebUI',
|
||||
'weixin': 'Weixin',
|
||||
'matrix': 'Matrix',
|
||||
'signal': 'Signal',
|
||||
}
|
||||
|
||||
|
||||
@@ -316,6 +376,61 @@ def is_cli_session_row_visible(row: dict) -> bool:
|
||||
return _count_user_turns(row) >= CLI_MIN_UNTITLED_USER_MESSAGE_COUNT
|
||||
|
||||
|
||||
# Every model_config marker Hermes Agent binds to ``parent_session_id`` in its
|
||||
# non-continuation child predicate (``_NON_CONTINUATION_CHILD_FILTER_SQL``):
|
||||
# ``_delegate_from`` (delegate_task), ``_branched_from`` (/branch) and
|
||||
# ``_reset_from`` (gateway reset children, stamped at creation or durably by
|
||||
# ``reopen_session()`` in ``gateway/session_recovery.py``).
|
||||
_MODEL_CONFIG_LINEAGE_KEYS = ('_delegate_from', '_branched_from', '_reset_from')
|
||||
|
||||
|
||||
def _branch_markers(row: dict | None) -> tuple[str, dict[str, str]]:
|
||||
"""Return ``(state, markers)`` for a row's ``model_config`` lineage identity.
|
||||
|
||||
Hermes Agent stamps explicit branches, delegate/subagent runs and gateway
|
||||
reset children in the ``model_config`` JSON column (see
|
||||
``_MODEL_CONFIG_LINEAGE_KEYS``); ``session_source='fork'`` alone only
|
||||
covers WebUI-created forks.
|
||||
|
||||
``state`` is one of:
|
||||
|
||||
- ``'none'``: no ``model_config``, or an object carrying no lineage marker;
|
||||
- ``'markers'``: every non-null marker is a non-empty string, returned by key;
|
||||
- ``'unknown'``: identity evidence exists but cannot be trusted (payload is
|
||||
not JSON, not a JSON object, too deeply nested to decode, or a marker is
|
||||
not a non-empty string). Callers must fail closed on this state and treat
|
||||
the row as a lineage boundary, never as a continuation.
|
||||
"""
|
||||
if not row:
|
||||
return 'none', {}
|
||||
raw = row.get('model_config')
|
||||
if raw is None:
|
||||
return 'none', {}
|
||||
if isinstance(raw, (str, bytes, bytearray)):
|
||||
if not raw.strip():
|
||||
return 'none', {}
|
||||
try:
|
||||
config = json.loads(raw)
|
||||
except (TypeError, ValueError, RecursionError):
|
||||
# RecursionError: a valid but pathologically deep payload exceeds
|
||||
# the decoder's recursion limit; it is untrusted identity evidence
|
||||
# like any other undecodable payload, never an escaping crash.
|
||||
return 'unknown', {}
|
||||
else:
|
||||
config = raw
|
||||
if not isinstance(config, dict):
|
||||
return 'unknown', {}
|
||||
markers: dict[str, str] = {}
|
||||
for key in _MODEL_CONFIG_LINEAGE_KEYS:
|
||||
value = config.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return 'unknown', {}
|
||||
markers[key] = value.strip()
|
||||
return ('markers', markers) if markers else ('none', {})
|
||||
|
||||
|
||||
def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
|
||||
"""Return True when ``child`` is the next segment of the same conversation.
|
||||
|
||||
@@ -323,19 +438,44 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
|
||||
by ``hermes -c`` also records a new child session; for sidebar projection it
|
||||
should continue the same visible conversation rather than becoming a
|
||||
separate child-session row. Plain parent/child links that started before the
|
||||
parent's ended boundary remain child sessions.
|
||||
parent's ended boundary remain child sessions. A small scheduling/write-order
|
||||
overlap between the handoff boundary timestamps is tolerated (#6931).
|
||||
|
||||
Do not collapse lineage across raw sources. A WebUI session that continues
|
||||
from a Telegram/CLI/etc. parent must remain visible as its own surface-owned
|
||||
conversation; otherwise the tip inherits the root's title/source metadata and
|
||||
can disappear under messaging/sidebar policies.
|
||||
|
||||
Explicit branches/delegates/resets never continue: a child whose
|
||||
``model_config`` ``_branched_from`` / ``_delegate_from`` / ``_reset_from``
|
||||
points at this parent is a separate conversation and must stay visible.
|
||||
Unparsable or non-string marker evidence fails closed as a boundary.
|
||||
Beyond the bounded tolerance window there is no reliable continuation
|
||||
signal — titles are user-controlled and non-unique, so they are never used
|
||||
to widen the window (#7021 re-gate).
|
||||
"""
|
||||
if not parent or not child:
|
||||
return False
|
||||
if str(child.get('session_source') or '').strip().lower() == 'fork':
|
||||
return False
|
||||
# Real Agent branches/delegates/resets are marked in model_config (not
|
||||
# session_source, which only WebUI-created forks carry). The marker must
|
||||
# reference THIS parent: compression continuations inherit the rotated
|
||||
# agent's model_config verbatim, so presence alone (a delegate's
|
||||
# continuation still carries the delegate's own ``_delegate_from``) would
|
||||
# misclassify real continuations. Untrusted identity evidence fails closed.
|
||||
marker_state, child_markers = _branch_markers(child)
|
||||
if marker_state == 'unknown':
|
||||
return False
|
||||
parent_id = str(parent.get('id') or '').strip()
|
||||
if parent_id and parent_id in child_markers.values():
|
||||
return False
|
||||
parent_source = str(parent.get('source') or '').strip().lower()
|
||||
child_source = str(child.get('source') or '').strip().lower()
|
||||
# Agent lineage excludes tool children even when the parent has the same
|
||||
# source (or no source); a timestamp overlap cannot override that boundary.
|
||||
if child_source == 'tool':
|
||||
return False
|
||||
if parent_source and child_source and parent_source != child_source:
|
||||
return False
|
||||
if parent.get('end_reason') not in {'compression', 'cli_close'}:
|
||||
@@ -347,9 +487,16 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
|
||||
# continuations when no boundary timestamp is available.
|
||||
return True
|
||||
try:
|
||||
return float(child.get('started_at') or 0) >= float(ended_at)
|
||||
child_started = float(child.get('started_at') or 0)
|
||||
parent_ended = float(ended_at)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if child_started >= parent_ended - CONTINUATION_STARTED_AT_TOLERANCE_SECONDS:
|
||||
return True
|
||||
# Beyond the bounded early-side window the child is a genuine concurrent
|
||||
# session — an exact title match is not evidence (titles are user-controlled
|
||||
# and non-unique, so it cannot extend the tolerance).
|
||||
return False
|
||||
|
||||
|
||||
def _continuation_root_id(rows_by_id: dict[str, dict], session_id: str | None) -> str | None:
|
||||
@@ -403,7 +550,7 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
|
||||
for children in children_by_parent.values():
|
||||
children.sort(key=lambda row: row.get('started_at') or 0, reverse=True)
|
||||
|
||||
def compression_tip(row: dict) -> tuple[dict | None, int]:
|
||||
def compression_tip(row: dict) -> tuple[dict | None, int, str | None]:
|
||||
"""Return the freshest importable continuation descendant for ``row``.
|
||||
|
||||
Compression parents can have multiple continuation-looking children when
|
||||
@@ -415,6 +562,9 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
|
||||
latest_importable = row if (row.get('actual_message_count') or 0) > 0 else None
|
||||
segment_count = 0
|
||||
best_depth = 1
|
||||
latest_project_id: str | None = None
|
||||
project_depth = 0
|
||||
project_score = -1.0
|
||||
best_score = (
|
||||
_as_score(latest_importable.get('last_activity'), latest_importable.get('started_at'))
|
||||
if latest_importable
|
||||
@@ -432,6 +582,17 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
|
||||
segment_count += 1
|
||||
|
||||
current_score = _as_score(current.get('last_activity'), current.get('started_at'))
|
||||
current_project_id = str(current.get('project_id') or '').strip()
|
||||
if (
|
||||
current_project_id
|
||||
and (
|
||||
current_score > project_score
|
||||
or (current_score == project_score and depth >= project_depth)
|
||||
)
|
||||
):
|
||||
latest_project_id = current_project_id
|
||||
project_depth = depth
|
||||
project_score = current_score
|
||||
if (
|
||||
(current.get('actual_message_count') or 0) > 0
|
||||
and (current_score > best_score or (current_score == best_score and depth >= best_depth))
|
||||
@@ -447,7 +608,7 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
|
||||
continue
|
||||
stack.append((child, depth + 1))
|
||||
|
||||
return latest_importable, max(segment_count, 1)
|
||||
return latest_importable, max(segment_count, 1), latest_project_id
|
||||
|
||||
projected = []
|
||||
for row in rows:
|
||||
@@ -456,13 +617,21 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
|
||||
|
||||
segment_count = 1
|
||||
tip = row
|
||||
lineage_project_id = str(row.get('project_id') or '').strip() or None
|
||||
if row.get('end_reason') in {'compression', 'cli_close'}:
|
||||
tip, segment_count = compression_tip(row)
|
||||
tip, segment_count, lineage_project_id = compression_tip(row)
|
||||
if not tip or (tip.get('actual_message_count') or 0) <= 0:
|
||||
continue
|
||||
|
||||
if tip is row:
|
||||
projected.append(dict(row))
|
||||
# The root can still be the freshest *importable* segment while a
|
||||
# newer EMPTY continuation carries the project assignment. Applying
|
||||
# the resolved lineage id here too keeps that assignment instead of
|
||||
# silently dropping it on the early-return path (#6659).
|
||||
root_only = dict(row)
|
||||
if lineage_project_id:
|
||||
root_only['project_id'] = lineage_project_id
|
||||
projected.append(root_only)
|
||||
continue
|
||||
|
||||
merged = dict(row)
|
||||
@@ -476,10 +645,12 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
|
||||
# expects from "Show agent sessions" sorted by activity.
|
||||
for key in (
|
||||
'id', 'model', 'message_count', 'actual_message_count', 'actual_user_message_count',
|
||||
'ended_at', 'end_reason', 'last_activity',
|
||||
'ended_at', 'end_reason', 'last_activity', 'archived',
|
||||
):
|
||||
if key in tip:
|
||||
merged[key] = tip[key]
|
||||
if lineage_project_id:
|
||||
merged['project_id'] = lineage_project_id
|
||||
if str(tip.get('source') or '').strip().lower() == 'tui':
|
||||
# TUI continuation rows are user-visible session segments (#6, #17,
|
||||
# ...), not opaque compression snapshots. Keep navigation pointed at
|
||||
@@ -512,7 +683,10 @@ def read_importable_agent_session_rows(
|
||||
log=None,
|
||||
exclude_sources: tuple[str, ...] | None = ("cron", "webui"),
|
||||
include_sources: tuple[str, ...] | None = None,
|
||||
) -> list[dict]:
|
||||
project_assignment: str | None = None,
|
||||
project_ids: tuple[str, ...] | None = None,
|
||||
return_window_exhaustion: bool = False,
|
||||
) -> list[dict] | tuple[list[dict], bool]:
|
||||
"""Return agent sessions projected as importable conversations.
|
||||
|
||||
Hermes Agent can create rows in ``state.db.sessions`` before a session has
|
||||
@@ -529,39 +703,70 @@ def read_importable_agent_session_rows(
|
||||
filter; callers that want an include-only query should explicitly pass
|
||||
``exclude_sources=None`` so the default exclusions do not also apply.
|
||||
|
||||
``project_assignment`` narrows to project-assigned (``'assigned'``) or
|
||||
project-free (``'unassigned'``) conversations; ``None`` returns both. The
|
||||
two are exact complements and key on the whole compression lineage, so a
|
||||
caller can budget assigned and unassigned conversations independently
|
||||
without either filter double-counting a lineage (#6659).
|
||||
|
||||
``project_ids`` narrows the assigned set further, to conversations whose
|
||||
LINEAGE is assigned to one of the given projects. It exists so a caller can
|
||||
give each project its own ``limit`` instead of sharing one global window: a
|
||||
single ``project_assignment='assigned'`` query orders by recency, so one busy
|
||||
project can consume the whole window and leave quieter projects unreachable
|
||||
(greptile P1 on #6659). Only meaningful together with
|
||||
``project_assignment='assigned'``; any other combination raises ValueError
|
||||
rather than silently returning an unnarrowed (and therefore starvable) set.
|
||||
|
||||
``limit`` bounds LOGICAL conversations, not raw rows: the slice is applied
|
||||
after lineage projection, and the raw candidate window is re-widened once
|
||||
if compression segments consumed it before the projection could fill the
|
||||
request.
|
||||
|
||||
``limit`` bounds the *recency slice*, not the returned row count. Subagent
|
||||
rows only render as children when their parent row is in the same payload,
|
||||
so subagent ancestors of selected rows are re-added afterwards and the
|
||||
result can exceed ``limit`` by the number of such anchors. Callers must
|
||||
therefore iterate the result rather than assume ``len(rows) <= limit``.
|
||||
|
||||
``return_window_exhaustion=True`` returns ``(rows, exhausted)`` instead of
|
||||
just ``rows``. ``exhausted`` is true only when the final bounded raw-candidate
|
||||
window was full while its logical projection still under-delivered ``limit``.
|
||||
That lets a caller distinguish a genuinely short database from a compressed
|
||||
or filtered projection that needs a narrow recovery query, without paying a
|
||||
count probe for ordinary short windows.
|
||||
|
||||
That recovery is deliberately bounded by the oversampled candidate set
|
||||
(``limit * 8`` newest sessions): it re-uses rows the projection already
|
||||
fetched and never issues an extra query, so an ancestor older than the
|
||||
oversample stays unresolved and its children render top-level, exactly as
|
||||
they did before. Widening that window is a ``candidate_limit`` change, not
|
||||
a change to this walk.
|
||||
(the ``CANDIDATE_WINDOW_MULTIPLIERS`` window that actually ran — ``limit *
|
||||
8``, or ``limit * 32`` when the first window was consumed and re-widened):
|
||||
it re-uses rows the projection already fetched and never issues an extra
|
||||
query, so an ancestor older than the oversample stays unresolved and its
|
||||
children render top-level, exactly as they did before. Widening that window
|
||||
is a ``CANDIDATE_WINDOW_MULTIPLIERS`` change, not a change to this walk.
|
||||
"""
|
||||
def _result(rows: list[dict], exhausted: bool = False):
|
||||
"""Preserve the list API unless this caller needs raw-window status."""
|
||||
return (rows, exhausted) if return_window_exhaustion else rows
|
||||
|
||||
wanted_project_ids: tuple[str, ...] = ()
|
||||
if project_ids is not None:
|
||||
if str(project_assignment or '').strip().lower() != 'assigned':
|
||||
raise ValueError(
|
||||
"project_ids requires project_assignment='assigned'"
|
||||
)
|
||||
wanted_project_ids = tuple(
|
||||
cleaned for value in project_ids if (cleaned := str(value or '').strip())
|
||||
)
|
||||
if not wanted_project_ids:
|
||||
# An empty narrowing selects nothing. Returning [] keeps that exact
|
||||
# instead of degrading to "every assigned conversation".
|
||||
return _result([])
|
||||
db_path = Path(db_path)
|
||||
if not db_path.exists():
|
||||
return []
|
||||
return _result([])
|
||||
|
||||
log = log or logger
|
||||
# Open read-only for this projection/listing path: it is a pure read, and
|
||||
# holding a write-capable handle on the live (multi-GB, WAL) state.db while
|
||||
# the agent streams into it adds needless checkpoint/lock surface (#5455).
|
||||
# The defensive index self-heal below still runs, but through a separate
|
||||
# short-lived writable connection on the rare missing-index path only.
|
||||
read_only_uri = f"{db_path.resolve().as_uri()}?mode=ro"
|
||||
try:
|
||||
conn = sqlite3.connect(read_only_uri, uri=True)
|
||||
except sqlite3.Error as exc:
|
||||
log.warning(
|
||||
"agent session listing read-only open failed for %s; falling back to writable connection: %s",
|
||||
db_path,
|
||||
exc,
|
||||
)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn = open_state_db_readonly(db_path, log=log)
|
||||
with closing(conn):
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
@@ -582,10 +787,11 @@ def read_importable_agent_session_rows(
|
||||
"Upgrade hermes-agent to fix this.",
|
||||
db_path,
|
||||
)
|
||||
return []
|
||||
return _result([])
|
||||
|
||||
parent_expr = _optional_col('parent_session_id', session_cols)
|
||||
session_source_expr = _optional_col('session_source', session_cols)
|
||||
model_config_expr = _optional_col('model_config', session_cols)
|
||||
ended_expr = _optional_col('ended_at', session_cols)
|
||||
end_reason_expr = _optional_col('end_reason', session_cols)
|
||||
user_id_expr = _optional_col('user_id', session_cols)
|
||||
@@ -596,6 +802,8 @@ def read_importable_agent_session_rows(
|
||||
origin_chat_id_expr = _optional_col('origin_chat_id', session_cols)
|
||||
origin_user_id_expr = _optional_col('origin_user_id', session_cols)
|
||||
platform_expr = _optional_col('platform', session_cols)
|
||||
archived_expr = _optional_col('archived', session_cols)
|
||||
project_id_expr = _optional_col('project_id', session_cols)
|
||||
# Older/minimal state.db schemas can have NO ``messages`` table at all,
|
||||
# or a ``messages`` table without a ``session_id`` / ``timestamp`` column.
|
||||
# The projection SQL below joins ``messages`` and aggregates
|
||||
@@ -611,11 +819,7 @@ def read_importable_agent_session_rows(
|
||||
use_messages_join = messages_has_session_id
|
||||
count_col = 'id' if 'id' in message_cols else 'session_id'
|
||||
|
||||
# Defensive index prime (#3887). The normal candidate-ordering shape uses
|
||||
# the agent's standard ``idx_messages_session ON messages(session_id,
|
||||
# timestamp)`` index; without it, large cron-only scans degrade badly.
|
||||
# Writable dbs self-heal by recreating the index. Read-only or locked dbs
|
||||
# fall back to the pre-aggregated cron-only path below instead of failing.
|
||||
# Index creation belongs to explicit drained maintenance, never a read.
|
||||
messages_index_present = False
|
||||
if messages_has_session_id and messages_has_timestamp:
|
||||
try:
|
||||
@@ -623,21 +827,7 @@ def read_importable_agent_session_rows(
|
||||
messages_index_present = any(str(row[1]) == "idx_messages_session" for row in cur.fetchall())
|
||||
except sqlite3.Error:
|
||||
messages_index_present = False
|
||||
if not messages_index_present:
|
||||
# Self-heal via a separate writable connection so the common
|
||||
# (index-present) path keeps its read-only handle. On a truly
|
||||
# read-only/locked db this fails and we degrade to the
|
||||
# pre-aggregated cron-only path below, exactly as before.
|
||||
try:
|
||||
with closing(sqlite3.connect(str(db_path))) as _heal:
|
||||
_heal.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_messages_session "
|
||||
"ON messages(session_id, timestamp)"
|
||||
)
|
||||
_heal.commit()
|
||||
messages_index_present = True
|
||||
except sqlite3.Error:
|
||||
pass # read-only db / locked / older schema — degrade gracefully
|
||||
|
||||
|
||||
if use_messages_join:
|
||||
actual_count_expr = f"COUNT(m.{count_col})"
|
||||
@@ -663,7 +853,6 @@ def read_importable_agent_session_rows(
|
||||
|
||||
where_clauses = ["s.source IS NOT NULL"]
|
||||
params: list[object] = []
|
||||
included = ()
|
||||
if include_sources:
|
||||
included = tuple(str(source) for source in include_sources if source)
|
||||
if included:
|
||||
@@ -676,11 +865,101 @@ def read_importable_agent_session_rows(
|
||||
placeholders = ", ".join("?" for _ in excluded)
|
||||
where_clauses.append(f"s.source NOT IN ({placeholders})")
|
||||
params.extend(excluded)
|
||||
if project_assignment is not None:
|
||||
wanted = str(project_assignment).strip().lower()
|
||||
if wanted not in PROJECT_ASSIGNMENT_FILTERS:
|
||||
raise ValueError(
|
||||
"project_assignment must be one of "
|
||||
f"{sorted(PROJECT_ASSIGNMENT_FILTERS)} or None"
|
||||
)
|
||||
if 'project_id' not in session_cols:
|
||||
# A schema that cannot persist assignments has no assigned rows,
|
||||
# and every row it does have is unassigned. Keep both answers
|
||||
# exact instead of emitting SQL against a missing column.
|
||||
if wanted == 'assigned':
|
||||
return _result([])
|
||||
elif {'parent_session_id', 'end_reason'} <= session_cols:
|
||||
continuation_checks = [
|
||||
"parent.end_reason IN ('compression', 'cli_close')",
|
||||
"(parent.source IS NULL OR child.source IS NULL "
|
||||
"OR LOWER(TRIM(parent.source)) = LOWER(TRIM(child.source)))",
|
||||
]
|
||||
if 'ended_at' in session_cols:
|
||||
continuation_checks.append(
|
||||
"(parent.ended_at IS NULL OR child.started_at >= parent.ended_at)"
|
||||
)
|
||||
if 'session_source' in session_cols:
|
||||
continuation_checks.append(
|
||||
"LOWER(TRIM(COALESCE(child.session_source, ''))) != 'fork'"
|
||||
)
|
||||
continuation_where = " AND ".join(continuation_checks)
|
||||
# An assignment anywhere in a compression lineage assigns the
|
||||
# whole logical conversation, so both filters must key on the
|
||||
# lineage, not the individual row: 'unassigned' is the exact
|
||||
# complement of 'assigned' (#6659).
|
||||
membership = "IN" if wanted == 'assigned' else "NOT IN"
|
||||
# ``project_ids`` narrows only the SEED: the lineage walk still
|
||||
# pulls in the whole compression chain around a seeded row, so a
|
||||
# per-project query returns the same logical conversations the
|
||||
# global one would have, minus the other projects'.
|
||||
seed_project_filter = ""
|
||||
if wanted_project_ids:
|
||||
placeholders = ", ".join("?" for _ in wanted_project_ids)
|
||||
seed_project_filter = (
|
||||
f"\n AND TRIM(seed.project_id) IN ({placeholders})"
|
||||
)
|
||||
where_clauses.append(
|
||||
f"""
|
||||
s.id {membership} (
|
||||
WITH RECURSIVE project_lineage(id) AS (
|
||||
SELECT seed.id
|
||||
FROM sessions seed
|
||||
WHERE seed.project_id IS NOT NULL
|
||||
AND TRIM(seed.project_id) != ''{seed_project_filter}
|
||||
UNION
|
||||
SELECT child.id
|
||||
FROM sessions child
|
||||
JOIN project_lineage lineage ON child.parent_session_id = lineage.id
|
||||
JOIN sessions parent ON parent.id = lineage.id
|
||||
WHERE {continuation_where}
|
||||
UNION
|
||||
SELECT parent.id
|
||||
FROM sessions child
|
||||
JOIN project_lineage lineage ON child.id = lineage.id
|
||||
JOIN sessions parent ON parent.id = child.parent_session_id
|
||||
WHERE {continuation_where}
|
||||
)
|
||||
SELECT id FROM project_lineage
|
||||
)
|
||||
"""
|
||||
)
|
||||
params.extend(wanted_project_ids)
|
||||
elif wanted == 'assigned':
|
||||
if wanted_project_ids:
|
||||
placeholders = ", ".join("?" for _ in wanted_project_ids)
|
||||
where_clauses.append(
|
||||
f"TRIM(COALESCE(s.project_id, '')) IN ({placeholders})"
|
||||
)
|
||||
params.extend(wanted_project_ids)
|
||||
else:
|
||||
where_clauses.append(
|
||||
"s.project_id IS NOT NULL AND TRIM(s.project_id) != ''"
|
||||
)
|
||||
else:
|
||||
where_clauses.append(
|
||||
"(s.project_id IS NULL OR TRIM(s.project_id) = '')"
|
||||
)
|
||||
|
||||
# Without ``idx_messages_session`` the correlated ``MAX(mx.timestamp)``
|
||||
# candidate ordering rescans ``messages`` once per session (seconds on
|
||||
# a few thousand sessions). Every missing-index projection — default
|
||||
# sidebar, gateway watcher, cron/webhook/kanban views — orders through
|
||||
# one read-only pre-aggregation pass instead; the listing never creates
|
||||
# the index itself (that is drained maintenance, see
|
||||
# ``scripts/ensure_state_db_read_indexes.py``).
|
||||
use_preaggregated_candidate_order = (
|
||||
use_messages_join
|
||||
and messages_has_timestamp
|
||||
and included == ("cron",)
|
||||
and not messages_index_present
|
||||
)
|
||||
if use_preaggregated_candidate_order:
|
||||
@@ -707,6 +986,7 @@ def read_importable_agent_session_rows(
|
||||
SELECT s.id, s.title, s.model, s.message_count,
|
||||
s.started_at, s.source,
|
||||
{session_source_expr},
|
||||
{model_config_expr},
|
||||
{user_id_expr},
|
||||
{chat_id_expr},
|
||||
{chat_type_expr},
|
||||
@@ -715,17 +995,30 @@ def read_importable_agent_session_rows(
|
||||
{origin_chat_id_expr},
|
||||
{origin_user_id_expr},
|
||||
{platform_expr},
|
||||
{project_id_expr},
|
||||
{parent_expr},
|
||||
{ended_expr},
|
||||
{end_reason_expr},
|
||||
{archived_expr},
|
||||
{actual_count_expr} AS actual_message_count,
|
||||
{user_message_count_expr} AS actual_user_message_count,
|
||||
{last_activity_expr} AS last_activity
|
||||
"""
|
||||
|
||||
def _project(raw_rows: list[dict]) -> list[dict]:
|
||||
projected = _project_agent_session_rows(raw_rows)
|
||||
# model_config is selected only so the continuation classifier can
|
||||
# read the real _branched_from/_delegate_from markers; it is internal
|
||||
# agent state and must not leak into the /api/sessions response.
|
||||
for row in projected:
|
||||
row.pop('model_config', None)
|
||||
projected = [_with_normalized_source(row) for row in projected]
|
||||
return [row for row in projected if is_cli_session_row_visible(row)]
|
||||
|
||||
if limit is not None:
|
||||
result_limit = max(0, int(limit))
|
||||
if result_limit == 0:
|
||||
return []
|
||||
return _result([])
|
||||
# The sidebar only needs a small visible window. Bound the expensive
|
||||
# messages join to a recent-activity candidate set instead of
|
||||
# aggregating every historical Hermes state.db session before
|
||||
@@ -734,7 +1027,6 @@ def read_importable_agent_session_rows(
|
||||
# can be resumed days later and should still surface at the top.
|
||||
# Oversampling preserves room for hidden compression segments or
|
||||
# other rows filtered after projection.
|
||||
candidate_limit = max(result_limit * 8, result_limit)
|
||||
if latest_messages_cte:
|
||||
candidate_cte = (
|
||||
"WITH {latest_messages_cte}, candidates AS (\n"
|
||||
@@ -764,8 +1056,7 @@ def read_importable_agent_session_rows(
|
||||
candidate_order_clause=candidate_order_clause,
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
candidate_sql = f"""
|
||||
{candidate_cte}
|
||||
{select_sql}
|
||||
FROM sessions s
|
||||
@@ -773,64 +1064,142 @@ def read_importable_agent_session_rows(
|
||||
{join_clause}
|
||||
{group_by_clause}
|
||||
{order_by_clause}
|
||||
""",
|
||||
[*params, candidate_limit],
|
||||
"""
|
||||
# Compression segments and the post-projection visibility filters are
|
||||
# spent from the RAW candidate window before the logical slice below,
|
||||
# so a lineage-heavy profile can return far fewer conversations than
|
||||
# asked for (greptile P1 on #6659). Widen the window once when it was
|
||||
# fully consumed and still came up short instead of silently
|
||||
# truncating the caller's request.
|
||||
projected: list[dict] = []
|
||||
window_exhausted = False
|
||||
for multiplier in CANDIDATE_WINDOW_MULTIPLIERS:
|
||||
candidate_limit = max(result_limit * multiplier, result_limit)
|
||||
cur.execute(candidate_sql, [*params, candidate_limit])
|
||||
raw_rows = [dict(row) for row in cur.fetchall()]
|
||||
projected = _project(raw_rows)
|
||||
window_exhausted = (
|
||||
len(raw_rows) >= candidate_limit
|
||||
and len(projected) < result_limit
|
||||
)
|
||||
if not window_exhausted:
|
||||
# Either the request is satisfied or the raw candidate window
|
||||
# was not binding, so projection has seen every candidate this
|
||||
# query can reveal.
|
||||
break
|
||||
selected = projected[:result_limit]
|
||||
|
||||
# The recency slice is per-row, but subagent rows are only renderable as
|
||||
# children: the sidebar nests a child under its parent solely when that
|
||||
# parent row is present in the same payload. A frozen orchestrator stops
|
||||
# writing while its leaves keep streaming, so the leaves win the recency
|
||||
# race and the parent falls outside the window — leaving the leaves to be
|
||||
# promoted to top-level sidebar rows. Re-add subagent parents that the
|
||||
# oversampled candidate set already projected (no extra query); webui
|
||||
# ancestors are left out because that sidebar bucket already has them.
|
||||
#
|
||||
# Bounded by construction: ``by_id`` only holds the candidates of the
|
||||
# window that was actually executed (``limit * 8``, or ``limit * 32``
|
||||
# when the re-widening pass above had to run), so an ancestor older
|
||||
# than that oversample is not recovered and its children stay
|
||||
# top-level — the pre-existing behaviour, narrowed rather than fixed.
|
||||
# Resolving those would need an unbounded per-row ancestor query on
|
||||
# the hot sidebar path; widen ``CANDIDATE_WINDOW_MULTIPLIERS`` instead
|
||||
# if the window proves too tight.
|
||||
# NOTE: this can return more than ``limit`` rows (see docstring).
|
||||
have = {row.get('id') for row in selected}
|
||||
by_id = {row.get('id'): row for row in projected if row.get('id')}
|
||||
pending = list(selected)
|
||||
while pending:
|
||||
row = pending.pop()
|
||||
if str(row.get('raw_source') or row.get('source') or '').strip().lower() != 'subagent':
|
||||
continue
|
||||
parent_id = row.get('parent_session_id')
|
||||
if not parent_id or parent_id in have:
|
||||
continue
|
||||
parent = by_id.get(parent_id)
|
||||
if parent is None:
|
||||
continue
|
||||
if str(parent.get('raw_source') or parent.get('source') or '').strip().lower() != 'subagent':
|
||||
continue
|
||||
selected.append(parent)
|
||||
have.add(parent_id)
|
||||
pending.append(parent)
|
||||
return _result(selected, window_exhausted)
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
{select_sql}
|
||||
FROM sessions s
|
||||
{join_clause}
|
||||
WHERE {' AND '.join(where_clauses)}
|
||||
{group_by_clause}
|
||||
{order_by_clause}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
return _result(_project([dict(row) for row in cur.fetchall()]))
|
||||
|
||||
|
||||
def read_assigned_project_row_counts(
|
||||
db_path: Path,
|
||||
log=None,
|
||||
exclude_sources: tuple[str, ...] | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Return ``{project_id: assigned raw row count}`` from ``state.db``.
|
||||
|
||||
A single GROUP BY over ``sessions.project_id``: no messages join, no lineage
|
||||
recursion, and an answer bounded by the number of distinct project ids. It
|
||||
exists so the assigned-recovery pass can tell WHICH projects still have rows
|
||||
it has not delivered, and pay a per-project follow-up query only for those,
|
||||
instead of one query per registered project (greptile P1 on #6659).
|
||||
|
||||
The counts are RAW rows, so a compression lineage counts once per segment.
|
||||
That makes the signal deliberately conservative — it can overestimate what a
|
||||
project still owes, never underestimate it, so a starved project is always
|
||||
detected while at worst one follow-up query comes back empty.
|
||||
"""
|
||||
db_path = Path(db_path)
|
||||
log = log or logger
|
||||
if not db_path.exists():
|
||||
return {}
|
||||
try:
|
||||
with closing(open_state_db_readonly(db_path, log)) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("PRAGMA table_info(sessions)")
|
||||
session_cols = {row[1] for row in cur.fetchall()}
|
||||
if not {'project_id', 'source'} <= session_cols:
|
||||
return {}
|
||||
where_clauses = [
|
||||
"s.source IS NOT NULL",
|
||||
"s.project_id IS NOT NULL",
|
||||
"TRIM(s.project_id) != ''",
|
||||
]
|
||||
params: list[object] = []
|
||||
excluded = tuple(
|
||||
str(source) for source in (exclude_sources or ()) if source
|
||||
)
|
||||
else:
|
||||
if excluded:
|
||||
placeholders = ", ".join("?" for _ in excluded)
|
||||
where_clauses.append(f"s.source NOT IN ({placeholders})")
|
||||
params.extend(excluded)
|
||||
cur.execute(
|
||||
f"""
|
||||
{select_sql}
|
||||
SELECT TRIM(s.project_id) AS project_id, COUNT(*) AS row_count
|
||||
FROM sessions s
|
||||
{join_clause}
|
||||
WHERE {' AND '.join(where_clauses)}
|
||||
{group_by_clause}
|
||||
{order_by_clause}
|
||||
GROUP BY TRIM(s.project_id)
|
||||
""",
|
||||
params,
|
||||
)
|
||||
projected = _project_agent_session_rows([dict(row) for row in cur.fetchall()])
|
||||
projected = [_with_normalized_source(row) for row in projected]
|
||||
projected = [row for row in projected if is_cli_session_row_visible(row)]
|
||||
if limit is None:
|
||||
return projected
|
||||
selected = projected[:max(0, int(limit))]
|
||||
|
||||
# The recency slice is per-row, but subagent rows are only renderable as
|
||||
# children: the sidebar nests a child under its parent solely when that
|
||||
# parent row is present in the same payload. A frozen orchestrator stops
|
||||
# writing while its leaves keep streaming, so the leaves win the recency
|
||||
# race and the parent falls outside the window — leaving the leaves to be
|
||||
# promoted to top-level sidebar rows. Re-add subagent parents that the
|
||||
# oversampled candidate set already projected (no extra query); webui
|
||||
# ancestors are left out because that sidebar bucket already has them.
|
||||
#
|
||||
# Bounded by construction: ``by_id`` only holds the ``limit * 8``
|
||||
# newest candidates, so an ancestor older than that oversample is not
|
||||
# recovered and its children stay top-level — the pre-existing
|
||||
# behaviour, narrowed rather than fixed. Resolving those would need an
|
||||
# unbounded per-row ancestor query on the hot sidebar path; widen
|
||||
# ``candidate_limit`` instead if the window proves too tight.
|
||||
# NOTE: this can return more than ``limit`` rows (see docstring).
|
||||
have = {row.get('id') for row in selected}
|
||||
by_id = {row.get('id'): row for row in projected if row.get('id')}
|
||||
pending = list(selected)
|
||||
while pending:
|
||||
row = pending.pop()
|
||||
if str(row.get('raw_source') or row.get('source') or '').strip().lower() != 'subagent':
|
||||
continue
|
||||
parent_id = row.get('parent_session_id')
|
||||
if not parent_id or parent_id in have:
|
||||
continue
|
||||
parent = by_id.get(parent_id)
|
||||
if parent is None:
|
||||
continue
|
||||
if str(parent.get('raw_source') or parent.get('source') or '').strip().lower() != 'subagent':
|
||||
continue
|
||||
selected.append(parent)
|
||||
have.add(parent_id)
|
||||
pending.append(parent)
|
||||
return selected
|
||||
|
||||
return {
|
||||
str(row[0]): int(row[1] or 0)
|
||||
for row in cur.fetchall()
|
||||
if str(row[0] or '').strip()
|
||||
}
|
||||
except Exception:
|
||||
log.debug("assigned project row-count probe failed", exc_info=True)
|
||||
return {}
|
||||
|
||||
|
||||
def _lineage_report_row(row: dict, role: str) -> dict:
|
||||
@@ -891,6 +1260,7 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops:
|
||||
|
||||
source_expr = _optional_col('source', session_cols)
|
||||
session_source_expr = _optional_col('session_source', session_cols)
|
||||
model_config_expr = _optional_col('model_config', session_cols)
|
||||
title_expr = _optional_col('title', session_cols)
|
||||
started_expr = _optional_col('started_at', session_cols, '0')
|
||||
ended_expr = _optional_col('ended_at', session_cols)
|
||||
@@ -905,6 +1275,7 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops:
|
||||
SELECT s.id,
|
||||
{source_expr},
|
||||
{session_source_expr},
|
||||
{model_config_expr},
|
||||
{title_expr},
|
||||
{started_expr},
|
||||
{parent_expr},
|
||||
@@ -951,6 +1322,7 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops:
|
||||
SELECT s.id,
|
||||
{source_expr},
|
||||
{session_source_expr},
|
||||
{model_config_expr},
|
||||
{title_expr},
|
||||
{started_expr},
|
||||
{parent_expr},
|
||||
@@ -1028,6 +1400,7 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st
|
||||
return {}
|
||||
session_source_expr = _optional_col('session_source', session_cols)
|
||||
source_expr = _optional_col('source', session_cols)
|
||||
model_config_expr = _optional_col('model_config', session_cols)
|
||||
message_count_expr = _optional_col('message_count', session_cols, '0')
|
||||
# Scoped fetch via PRIMARY KEY + idx_sessions_parent rather than a
|
||||
# full table scan. The sessions table grows unbounded over time
|
||||
@@ -1064,7 +1437,7 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st
|
||||
placeholders = ','.join('?' * len(chunk))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT s.id, {source_expr}, {session_source_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr}
|
||||
SELECT s.id, {source_expr}, {session_source_expr}, {model_config_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr}
|
||||
FROM sessions s
|
||||
WHERE s.id IN ({placeholders})
|
||||
""",
|
||||
@@ -1093,7 +1466,7 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st
|
||||
placeholders = ','.join('?' * len(chunk))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT s.id, {source_expr}, {session_source_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr}
|
||||
SELECT s.id, {source_expr}, {session_source_expr}, {model_config_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr}
|
||||
FROM sessions s
|
||||
WHERE s.parent_session_id IN ({placeholders})
|
||||
""",
|
||||
|
||||
+37
@@ -17,6 +17,7 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
from api.config import STATE_DIR, get_config, load_settings
|
||||
from api.helpers import request_declares_body
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1207,6 +1208,42 @@ def check_auth(handler, parsed) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def check_auth_or_close(handler, parsed) -> bool:
|
||||
"""Check auth; when rejected, close so an unread body can't poison HTTP/1.1 reuse.
|
||||
|
||||
The flag is armed BEFORE check_auth() writes its 401/302, so end_headers()
|
||||
can advertise ``Connection: close``; success restores the prior flag so an
|
||||
authenticated request keeps its keep-alive.
|
||||
|
||||
Armed ONLY when the request's framing declares a body still queued in
|
||||
``rfile`` (see ``request_declares_body()``), which is the same rule the other
|
||||
reject-before-read sites apply through ``arm_connection_close_if_body_pending()``.
|
||||
Arming unconditionally was the mirror image of the over-close this PR already
|
||||
fixed on the sidecar path: a body-less POST that failed auth answered
|
||||
``401`` WITH ``Connection: close`` and dropped the client's pipelined
|
||||
follow-up, killing a healthy keep-alive connection for no framing reason.
|
||||
Verified on the wire, pipelined down one socket against the production
|
||||
handler: ``POST /api/session/new`` with no ``Content-Length`` (and with
|
||||
``Content-Length: 0``) answered ``401`` + ``Connection: close`` and the
|
||||
following ``GET /api/auth/status`` was never served. The helper cannot be
|
||||
swapped in directly here because the arming has to happen before
|
||||
``check_auth()`` writes its response and be undone if it succeeds.
|
||||
|
||||
``close_connection`` only exists once BaseHTTPRequestHandler has parsed a
|
||||
request line, so partially-built handler stubs may not have it at all. Read
|
||||
it defensively and only arm/restore when it was really there: inventing the
|
||||
attribute on a stub would leave a bogus keep-alive verdict behind.
|
||||
"""
|
||||
if not hasattr(handler, 'close_connection') or not request_declares_body(handler):
|
||||
return check_auth(handler, parsed)
|
||||
prior_close = handler.close_connection
|
||||
handler.close_connection = True
|
||||
if check_auth(handler, parsed):
|
||||
handler.close_connection = prior_close
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_loopback(addr: str) -> bool:
|
||||
"""Return True if *addr* is a loopback address (127.x.x.x, ::1, or ::ffff:127.x.x.x)."""
|
||||
import ipaddress as _ipaddress
|
||||
|
||||
+211
-57
@@ -1020,6 +1020,29 @@ def _record_async_delegation_accepted(
|
||||
)
|
||||
|
||||
|
||||
def _wakeup_refusal_is_transient(resp: dict | None, status: int) -> bool:
|
||||
"""True when the target refused the wake-up for a reason it will recover from.
|
||||
|
||||
Such refusals are not delivery failures of the completion itself, so they
|
||||
must not consume its bounded durable delivery budget. Otherwise a burst of
|
||||
refusals (e.g. ``agent_runtime_stale`` answers while the Agent runtime is
|
||||
being replaced) terminally drops a completion whose origin session is alive:
|
||||
|
||||
* any payload that explicitly says ``retryable: True`` (stale runtime);
|
||||
* the paused-wakeup contract (``process_wakeup_paused``);
|
||||
* a session busy with another turn (``active_stream_id`` on a 409).
|
||||
"""
|
||||
if not isinstance(resp, dict):
|
||||
return False
|
||||
if resp.get("retryable") is True:
|
||||
return True
|
||||
if status != 409:
|
||||
return False
|
||||
if resp.get("error") == "process_wakeup_paused":
|
||||
return True
|
||||
return bool(resp.get("active_stream_id"))
|
||||
|
||||
|
||||
def _start_async_delegation_wakeup_turn(
|
||||
session_id: str,
|
||||
wakeup_prompt: str,
|
||||
@@ -1059,7 +1082,11 @@ def _start_async_delegation_wakeup_turn(
|
||||
)
|
||||
return
|
||||
|
||||
release_async_delegation_delivery(evt, claim)
|
||||
transient = _wakeup_refusal_is_transient(resp, status)
|
||||
if transient:
|
||||
release_async_delegation_delivery(evt, claim, retryable=True)
|
||||
else:
|
||||
release_async_delegation_delivery(evt, claim)
|
||||
_retry_unclaimed_async_delegation_event(
|
||||
process_registry, evt, keep_legacy_retrying=True
|
||||
)
|
||||
@@ -1068,6 +1095,14 @@ def _start_async_delegation_wakeup_turn(
|
||||
"async delegation wakeup paused for session %s; delivery remains retryable",
|
||||
session_id,
|
||||
)
|
||||
elif transient:
|
||||
logger.info(
|
||||
"async delegation wakeup refused transiently for session %s "
|
||||
"(status=%s err=%r); attempt refunded, durable retry scheduled",
|
||||
session_id,
|
||||
status,
|
||||
(resp or {}).get("error"),
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"async delegation wakeup not accepted for session %s: "
|
||||
@@ -1086,6 +1121,13 @@ def _start_async_delegation_wakeup_turn(
|
||||
session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
# The wakeup turn has resolved (accepted, rejected, or raised): the
|
||||
# per-origin admission slot is free for the next backlogged
|
||||
# completion. Never release earlier — the slot must stay reserved
|
||||
# for the whole claim→turn window so siblings defer without
|
||||
# consuming the finite delivery budget (#6959).
|
||||
_release_async_delegation_wakeup_admission(session_id)
|
||||
|
||||
threading.Thread(
|
||||
target=_runner,
|
||||
@@ -1094,6 +1136,42 @@ def _start_async_delegation_wakeup_turn(
|
||||
).start()
|
||||
|
||||
|
||||
# ── Per-origin wakeup admission reservation (#6959) ────────────────────────
|
||||
# A durable claim has a finite delivery-attempt budget, and the pre-claim busy
|
||||
# check is NOT atomic with turn publication: several completed delegations for
|
||||
# one idle origin can all pass it, each obtain a durable claim, then race for a
|
||||
# single ``start_session_turn`` admission slot. The losers' transient 409s
|
||||
# release already-counted claims, and repeated rounds can drive completed rows
|
||||
# to the terminal ``dropped`` state. Reserve the per-origin admission slot
|
||||
# ATOMICALLY before claiming so at most one async-delegation wakeup per session
|
||||
# is in flight at a time; siblings that lose the reservation defer WITHOUT
|
||||
# claiming (no delivery attempt consumed). The reservation is in-process only:
|
||||
# the wakeup thread releases it on every exit path and it vanishes on restart,
|
||||
# so it can never strand a durable claim.
|
||||
_ASYNC_DELEGATION_WAKEUP_ADMISSION_LOCK = threading.Lock()
|
||||
_ASYNC_DELEGATION_WAKEUP_ADMISSION_INFLIGHT: set[str] = set()
|
||||
|
||||
|
||||
def _try_reserve_async_delegation_wakeup_admission(session_id: str) -> bool:
|
||||
"""Atomically reserve the single in-flight wakeup slot for *session_id*.
|
||||
|
||||
Returns False when another async-delegation wakeup for the same origin is
|
||||
already in flight (claimed but not yet resolved), so the caller can defer
|
||||
without touching the finite delivery budget.
|
||||
"""
|
||||
with _ASYNC_DELEGATION_WAKEUP_ADMISSION_LOCK:
|
||||
if session_id in _ASYNC_DELEGATION_WAKEUP_ADMISSION_INFLIGHT:
|
||||
return False
|
||||
_ASYNC_DELEGATION_WAKEUP_ADMISSION_INFLIGHT.add(session_id)
|
||||
return True
|
||||
|
||||
|
||||
def _release_async_delegation_wakeup_admission(session_id: str) -> None:
|
||||
"""Release the in-flight wakeup slot for *session_id* (idempotent)."""
|
||||
with _ASYNC_DELEGATION_WAKEUP_ADMISSION_LOCK:
|
||||
_ASYNC_DELEGATION_WAKEUP_ADMISSION_INFLIGHT.discard(session_id)
|
||||
|
||||
|
||||
def _process_async_delegation_event(
|
||||
evt: dict,
|
||||
*,
|
||||
@@ -1114,56 +1192,87 @@ def _process_async_delegation_event(
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
claim = claim_async_delegation_delivery(evt, "webui-background")
|
||||
except Exception:
|
||||
_requeue_async_delegation_event(process_registry, evt)
|
||||
return
|
||||
if claim is None:
|
||||
completion_queue = getattr(process_registry, "completion_queue", None)
|
||||
schedule_async_delegation_claim_retry(evt, completion_queue)
|
||||
return
|
||||
|
||||
try:
|
||||
wakeup_prompt_raw = format_wakeup_prompt(evt)
|
||||
wakeup_prompt = wakeup_prompt_raw.strip() if wakeup_prompt_raw else ""
|
||||
if not wakeup_prompt:
|
||||
raise RuntimeError("async delegation completion could not be formatted")
|
||||
except Exception:
|
||||
# A formatting failure is a permanent, non-retryable defect (a malformed
|
||||
# event will never format correctly), so it must stay on the bounded
|
||||
# one-shot path — never keep_legacy_retrying, or it would loop forever.
|
||||
release_async_delegation_delivery(evt, claim)
|
||||
_retry_unclaimed_async_delegation_event(process_registry, evt)
|
||||
logger.warning(
|
||||
"async delegation completion could not be formatted for session %s",
|
||||
session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
_start_async_delegation_wakeup_turn(
|
||||
session_id,
|
||||
wakeup_prompt,
|
||||
delegation_id=delegation_id,
|
||||
evt=evt,
|
||||
claim=claim,
|
||||
process_registry=process_registry,
|
||||
)
|
||||
except Exception:
|
||||
# A post-format dispatch failure against a resolved target is transient
|
||||
# (the target may accept on a later pass), so keep the legacy completion
|
||||
# retrying rather than dropping it after one bounded attempt.
|
||||
release_async_delegation_delivery(evt, claim)
|
||||
# Atomic per-origin wakeup admission (#6959): the busy check above is a
|
||||
# pre-check, not a reservation — several idle completions for one origin
|
||||
# can all pass it together. Only one may own the in-flight wakeup slot; a
|
||||
# sibling that loses this reservation must NOT claim, because a claim
|
||||
# released by a transient 409 would count against the finite delivery
|
||||
# budget. The admitted wakeup releases the slot when its turn resolves.
|
||||
if not _try_reserve_async_delegation_wakeup_admission(session_id):
|
||||
_retry_unclaimed_async_delegation_event(
|
||||
process_registry, evt, keep_legacy_retrying=True
|
||||
)
|
||||
logger.warning(
|
||||
"server-side async delegation dispatch failed for session %s",
|
||||
session_id,
|
||||
exc_info=True,
|
||||
process_registry,
|
||||
evt,
|
||||
keep_legacy_retrying=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Ownership-transfer guard (#6959 gate): the outer finally releases the
|
||||
# per-origin reservation on EVERY exit below — claim exception, claim-None,
|
||||
# formatting failure, dispatch failure, or any exception raised by the
|
||||
# retry helpers themselves — UNLESS the wakeup thread successfully started,
|
||||
# in which case the thread's own finally now owns the release. No raisable
|
||||
# call can therefore strand the reservation (a stranded slot would defer
|
||||
# every later completion for this origin forever, without delivery).
|
||||
transfer_owned = False
|
||||
try:
|
||||
try:
|
||||
claim = claim_async_delegation_delivery(evt, "webui-background")
|
||||
except Exception:
|
||||
_requeue_async_delegation_event(process_registry, evt)
|
||||
return
|
||||
if claim is None:
|
||||
completion_queue = getattr(process_registry, "completion_queue", None)
|
||||
schedule_async_delegation_claim_retry(evt, completion_queue)
|
||||
return
|
||||
|
||||
try:
|
||||
wakeup_prompt_raw = format_wakeup_prompt(evt)
|
||||
wakeup_prompt = wakeup_prompt_raw.strip() if wakeup_prompt_raw else ""
|
||||
if not wakeup_prompt:
|
||||
raise RuntimeError("async delegation completion could not be formatted")
|
||||
except Exception:
|
||||
# A formatting failure is a permanent, non-retryable defect (a malformed
|
||||
# event will never format correctly), so it must stay on the bounded
|
||||
# one-shot path — never keep_legacy_retrying, or it would loop forever.
|
||||
release_async_delegation_delivery(evt, claim)
|
||||
_retry_unclaimed_async_delegation_event(process_registry, evt)
|
||||
logger.warning(
|
||||
"async delegation completion could not be formatted for session %s",
|
||||
session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
_start_async_delegation_wakeup_turn(
|
||||
session_id,
|
||||
wakeup_prompt,
|
||||
delegation_id=delegation_id,
|
||||
evt=evt,
|
||||
claim=claim,
|
||||
process_registry=process_registry,
|
||||
)
|
||||
except Exception:
|
||||
# A post-format dispatch failure against a resolved target is transient
|
||||
# (the target may accept on a later pass), so keep the legacy completion
|
||||
# retrying rather than dropping it after one bounded attempt.
|
||||
release_async_delegation_delivery(evt, claim)
|
||||
_retry_unclaimed_async_delegation_event(
|
||||
process_registry, evt, keep_legacy_retrying=True
|
||||
)
|
||||
logger.warning(
|
||||
"server-side async delegation dispatch failed for session %s",
|
||||
session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
# The wakeup thread is running: its own finally owns the release from
|
||||
# here on (set only after Thread.start() returns, so a mid-start
|
||||
# failure still releases here).
|
||||
transfer_owned = True
|
||||
finally:
|
||||
if not transfer_owned:
|
||||
_release_async_delegation_wakeup_admission(session_id)
|
||||
|
||||
|
||||
def _resolve_completion_target(
|
||||
@@ -1571,14 +1680,44 @@ def _session_has_active_turn(session_id: str) -> bool:
|
||||
``_emit_to_session_streams``) to map a stream back to its owning session.
|
||||
ACTIVE_RUNS is registered at agent-worker start and removed in the worker's
|
||||
outer ``finally``, so it survives cancel/reconnect races better than
|
||||
STREAMS. There is a brief window where ``_start_chat_stream_for_session``
|
||||
has populated STREAMS but the worker thread has not yet called
|
||||
``register_active_run``; in that window this returns False and the
|
||||
subsequent ``start_session_turn`` is rejected with a 409 by
|
||||
``_start_chat_stream_for_session``'s own active-stream guard — i.e. the
|
||||
same lock /api/chat/start uses is the authoritative race backstop.
|
||||
STREAMS. We ALSO count a same-session live STREAMS entry (via the
|
||||
synchronously-populated STREAM_SESSION_OWNERS registry): the worker
|
||||
publishes its stream BEFORE it registers in ACTIVE_RUNS, and that
|
||||
pre-registration window must not look idle to a sibling completion
|
||||
(#6959 gate — see the STREAMS check below). ``_start_chat_stream_for_session``'s
|
||||
own active-stream guard remains the authoritative 409 backstop for any
|
||||
residual race.
|
||||
"""
|
||||
return bool(_active_run_ids_for_session(session_id, attachable_only=False))
|
||||
# ACTIVE_RUNS rows (including a bounded cancelling-run unwind window,
|
||||
# per _active_run_ids_for_session).
|
||||
if _active_run_ids_for_session(session_id, attachable_only=False):
|
||||
return True
|
||||
|
||||
# Pre-ACTIVE_RUNS publication window (#6959 gate): the agent worker
|
||||
# publishes its stream — register_stream_owner() first, then the live
|
||||
# STREAMS channel — BEFORE it registers in ACTIVE_RUNS. In that window a
|
||||
# same-session live STREAMS entry means a turn is already (or about to be)
|
||||
# active, so it must count here: otherwise a sibling async-delegation
|
||||
# completion would pass the busy pre-check, reserve the per-origin
|
||||
# admission, claim, and then 409 against the already-published stream —
|
||||
# burning the finite delivery-attempt budget on every retry round. The
|
||||
# worker's teardown finally pops STREAMS (and unregisters the stream owner)
|
||||
# before the turn-teardown idle-hook drain runs, so the just-ended turn
|
||||
# does not keep the session busy.
|
||||
from api import config as _cfg
|
||||
|
||||
try:
|
||||
with _cfg.STREAMS_LOCK:
|
||||
live_stream_ids = list((_cfg.STREAMS or {}).keys())
|
||||
with _cfg.STREAM_SESSION_OWNERS_LOCK:
|
||||
stream_owners = dict(_cfg.STREAM_SESSION_OWNERS or {})
|
||||
except Exception:
|
||||
logger.debug("STREAMS active-turn check failed", exc_info=True)
|
||||
return False
|
||||
for _stream_id in live_stream_ids:
|
||||
if str(stream_owners.get(_stream_id) or "") == str(session_id or ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _start_server_side_wakeup_turn(
|
||||
@@ -1744,7 +1883,22 @@ def recover_processes_for_webui(process_registry=None, get_session_fn=None) -> i
|
||||
try:
|
||||
proc_session = process_registry.get(process_id)
|
||||
session_key = str(getattr(proc_session, "session_key", "") or "")
|
||||
if not session_key or get_session_fn(session_key, metadata_only=True) is None:
|
||||
if not session_key:
|
||||
continue
|
||||
# The session resolver (``api.models._resolve_session_once``)
|
||||
# raises ``KeyError(sid)`` for a missing session — that is its
|
||||
# documented contract for the "owner gone" outcome, not a
|
||||
# fault. Treat it identically to a ``None`` return: skip the
|
||||
# process without logging, and let a neighbouring live
|
||||
# process still rebind. The narrow ``except KeyError`` lives
|
||||
# INSIDE the outer try so an unrelated ``KeyError`` from
|
||||
# ``process_registry.get()`` (or any routing-index work) still
|
||||
# surfaces in the existing warning path.
|
||||
try:
|
||||
resolved = get_session_fn(session_key, metadata_only=True)
|
||||
except KeyError:
|
||||
continue
|
||||
if resolved is None:
|
||||
continue
|
||||
except Exception:
|
||||
logger.warning(
|
||||
|
||||
+89
-12
@@ -257,26 +257,103 @@ def _run_codex_runtime_command(arg_string: str) -> str:
|
||||
|
||||
|
||||
def _run_reload_mcp_command() -> str:
|
||||
"""Execute the MCP reconnect path and return a short user-facing summary."""
|
||||
"""Execute the MCP reconnect path and return a short user-facing summary.
|
||||
|
||||
Scoped to the request profile: only that profile's connections are shut
|
||||
down and rediscovered from that profile's config, so other profiles'
|
||||
same-named servers keep running (agents with profile-scoped MCP; older
|
||||
agents keep their process-wide reload).
|
||||
"""
|
||||
with _RELOAD_MCP_LOCK:
|
||||
try:
|
||||
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _servers, _lock
|
||||
import importlib
|
||||
from tools.mcp_tool import _servers, _lock
|
||||
# Resolved by full name (like the ``from`` import above) so a stubbed
|
||||
# ``tools.mcp_tool`` without a ``tools`` package still works.
|
||||
_core = importlib.import_module("tools.mcp_tool")
|
||||
from api.agent_compat import agent_attr
|
||||
from api.mcp_runtime import (
|
||||
accepts_keywords,
|
||||
clear_profile_connect_cooldowns,
|
||||
ledger_key_serves_view,
|
||||
mcp_key_name,
|
||||
mcp_runtime_scope,
|
||||
)
|
||||
|
||||
shutdown_mcp_servers = agent_attr(
|
||||
"tools.mcp_tool", "shutdown_mcp_servers", "tools.mcp_tool_lifecycle"
|
||||
)
|
||||
discover_mcp_tools = agent_attr(
|
||||
"tools.mcp_tool", "discover_mcp_tools", "tools.mcp_tool_discovery"
|
||||
)
|
||||
try:
|
||||
register_mcp_servers = agent_attr(
|
||||
"tools.mcp_tool", "register_mcp_servers", "tools.mcp_tool_discovery"
|
||||
)
|
||||
except Exception:
|
||||
register_mcp_servers = None # older agent: no overlay reconciliation
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to import MCP runtime for /reload-mcp", exc_info=True)
|
||||
raise RuntimeError("MCP runtime unavailable") from exc
|
||||
|
||||
try:
|
||||
with _lock:
|
||||
old_servers = set(_servers.keys())
|
||||
with mcp_runtime_scope("/reload-mcp") as view:
|
||||
if not view.trusted:
|
||||
# Resetting under an unconfirmed scope could stop another profile's
|
||||
# connection or rebuild this one under the wrong owner.
|
||||
raise RuntimeError(
|
||||
"MCP reload unavailable: this profile's MCP runtime scope could not be "
|
||||
"confirmed (a chat turn on this profile may be running); retry when it finishes"
|
||||
)
|
||||
try:
|
||||
# Profile-scoped teardown needs both the scoped ledger and a
|
||||
# shutdown that takes scope/names; otherwise keep the legacy reload.
|
||||
legacy = view.legacy or not accepts_keywords(shutdown_mcp_servers, "scope", "names")
|
||||
|
||||
shutdown_mcp_servers()
|
||||
new_tools = discover_mcp_tools()
|
||||
def live_names() -> set:
|
||||
# Same criteria as the status page: the connection serves this
|
||||
# profile (owned or adopted) and is live. A parked task (failed
|
||||
# spawn retained for retry) has no session and is not "connected".
|
||||
return {
|
||||
mcp_key_name(key) for key, server in _servers.items()
|
||||
if (legacy or ledger_key_serves_view(_core, key, view))
|
||||
and getattr(server, "session", True) is not None
|
||||
}
|
||||
|
||||
with _lock:
|
||||
connected_servers = set(_servers.keys())
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to reload MCP servers", exc_info=True)
|
||||
raise RuntimeError("Failed to reload MCP servers") from exc
|
||||
with _lock:
|
||||
old_servers = live_names()
|
||||
# Every ledger entry serving this profile, parked ones included:
|
||||
# a parked task left in place would make discovery skip the server.
|
||||
owned_names = {
|
||||
mcp_key_name(key) for key in _servers
|
||||
if ledger_key_serves_view(_core, key, view)
|
||||
}
|
||||
|
||||
if legacy:
|
||||
shutdown_mcp_servers()
|
||||
else:
|
||||
# ``names`` is required: ``scope=None`` alone is the process-wide
|
||||
# wildcard. Only names with a ledger key are passed; shutdown
|
||||
# ignores anything else, and a key of another owner is never selected.
|
||||
shutdown_mcp_servers(scope=view.registry_scope, names=owned_names)
|
||||
# The wildcard also dropped connect backoff; keep that for this
|
||||
# profile's own failed servers so the reload retries them now.
|
||||
clear_profile_connect_cooldowns(_core, view)
|
||||
# A connection this profile ADOPTED (another profile's identical
|
||||
# route) is not torn down above, and ``discover_mcp_tools()``
|
||||
# returns before its reconcile step when the profile's config
|
||||
# has no server left, so the adopted tools would stay callable.
|
||||
# ``register_mcp_servers({})`` judges every server serving this
|
||||
# scope against the profile's config on disk and detaches only
|
||||
# this profile's overlay; the owner's connection keeps running.
|
||||
if register_mcp_servers is not None and view.registry_scope is not None:
|
||||
register_mcp_servers({})
|
||||
new_tools = discover_mcp_tools()
|
||||
|
||||
with _lock:
|
||||
connected_servers = live_names()
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to reload MCP servers", exc_info=True)
|
||||
raise RuntimeError("Failed to reload MCP servers") from exc
|
||||
|
||||
added = connected_servers - old_servers
|
||||
removed = old_servers - connected_servers
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Read-only routing hints for externally compressed WebUI sessions.
|
||||
|
||||
SQLite owns compression lineage; sidecar snapshot flags may predate a
|
||||
Desktop/CLI rotation. Never reopen a sealed parent or mutate Agent state here.
|
||||
"""
|
||||
import inspect
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_local_interactive_persisted_source(source):
|
||||
return isinstance(source, str) and source in {
|
||||
"webui", "tui", "cli", "desktop", "acp",
|
||||
}
|
||||
|
||||
|
||||
def durable_compression_continuation(session):
|
||||
"""Return (sealed, resumable tip), without making a recovery write.
|
||||
|
||||
A known sealed parent without a safe tip stays sealed (no sidecar fallback).
|
||||
Older Agent installations without this read API retain legacy behavior.
|
||||
"""
|
||||
from api.profiles import _PROFILE_ID_RE, _resolve_profile_home_for_name
|
||||
|
||||
sid = str(getattr(session, "session_id", "") or "")
|
||||
profile = str(getattr(session, "profile", None) or "default")
|
||||
if not sid or (profile != "default" and not _PROFILE_ID_RE.fullmatch(profile)):
|
||||
return False, None
|
||||
db = None
|
||||
sealed = False
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
|
||||
path = Path(_resolve_profile_home_for_name(profile)) / "state.db"
|
||||
if not path.is_file():
|
||||
return False, None
|
||||
db = SessionDB(path, read_only=True)
|
||||
# Establish the complete read API before accepting SQLite authority.
|
||||
# Old Agents must retain legacy sidecar recovery, not a sealed null tip.
|
||||
for name in ('get_session', 'get_compression_tip'):
|
||||
method = getattr(db, name, None)
|
||||
if not callable(method):
|
||||
return False, None
|
||||
try:
|
||||
inspect.signature(method).bind(sid)
|
||||
except (TypeError, ValueError):
|
||||
return False, None
|
||||
parent = db.get_session(sid)
|
||||
if not parent or parent.get("end_reason") != "compression":
|
||||
return False, None
|
||||
sealed = True
|
||||
tip = db.get_compression_tip(sid)
|
||||
if not tip or tip == sid:
|
||||
return True, None
|
||||
child = db.get_session(tip)
|
||||
if not child or not _is_local_interactive_persisted_source(child.get("source")):
|
||||
return True, None
|
||||
for row in (parent, child):
|
||||
if row.get("profile_name") not in (None, "", profile):
|
||||
return True, None
|
||||
# Only the observed automatic idle closure is resumable here. Explicit
|
||||
# resets/closures and unknown future reasons must not become redirects.
|
||||
reason = child.get("end_reason")
|
||||
if reason not in (None, "", "idle_timeout"):
|
||||
return True, None
|
||||
if child.get("ended_at") is not None and reason != "idle_timeout":
|
||||
return True, None
|
||||
if db.get_compression_tip(sid) != tip:
|
||||
return True, None
|
||||
return True, tip
|
||||
except (ImportError, AttributeError, TypeError):
|
||||
return sealed, None
|
||||
except Exception:
|
||||
logger.debug("Could not resolve durable compression continuation", exc_info=True)
|
||||
return sealed, None
|
||||
finally:
|
||||
if db is not None:
|
||||
db.close()
|
||||
+1861
-118
File diff suppressed because it is too large.
Load diff
+373
-67
@@ -38,6 +38,7 @@ from api.config import (
|
||||
from api.helpers import _redact_text, redact_session_data
|
||||
from api.models import clear_process_wakeup_pause, get_session, merge_session_messages_append_only
|
||||
from api.run_journal import RunJournalWriter, bound_run_journal_snapshot_args
|
||||
from api.turn_journal import append_turn_journal_event_for_stream
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,6 +47,21 @@ _STREAM_RUN_IDS: dict[str, str] = {}
|
||||
_STREAM_RUN_LIFECYCLE: dict[str, dict[str, Any]] = {}
|
||||
_STREAM_RUN_STARTING_CONDITION = threading.Condition()
|
||||
GATEWAY_RUN_ID_WAIT_TIMEOUT = 5.0
|
||||
# stream_id -> (base_url, api_key) of the Gateway the stream's run lives on, for Stop
|
||||
# and approval replies; a reattached run's Gateway need not be the process profile's.
|
||||
_STREAM_ENDPOINTS: dict[str, tuple[str, str]] = {}
|
||||
|
||||
|
||||
def gateway_run_endpoint(run_id: str) -> tuple[str, str]:
|
||||
"""URL and key of the Gateway that owns run_id; the process Gateway if no live stream holds it."""
|
||||
run_id = str(run_id or "").strip()
|
||||
with _STREAM_RUN_STARTING_CONDITION:
|
||||
for stream_id, mapped in list(_STREAM_RUN_IDS.items()):
|
||||
if run_id and mapped == run_id and stream_id in _STREAM_ENDPOINTS:
|
||||
return _STREAM_ENDPOINTS[stream_id]
|
||||
from api.config import get_config
|
||||
|
||||
return _gateway_base_url(get_config()), _gateway_api_key()
|
||||
|
||||
|
||||
def _mark_gateway_run_starting(stream_id: str) -> None:
|
||||
@@ -153,6 +169,9 @@ _WEBUI_GATEWAY_BASE_URL_ENV = "HERMES_WEBUI_GATEWAY_BASE_URL"
|
||||
_WEBUI_GATEWAY_API_KEY_ENV = "HERMES_WEBUI_GATEWAY_API_KEY"
|
||||
_WEBUI_GATEWAY_USE_RUNS_API_ENV = "HERMES_WEBUI_GATEWAY_USE_RUNS_API"
|
||||
_GATEWAY_CHAT_BACKENDS = {"gateway", "api_server", "api-server"}
|
||||
# Backend tag of the in-process WebUI runtime. Local workers register their
|
||||
# active run with it; cache-only Steer only enqueues on this explicit value.
|
||||
WEBUI_LOCAL_CHAT_BACKEND = "legacy"
|
||||
|
||||
|
||||
def _gateway_model_field(model: str | None) -> str:
|
||||
@@ -272,7 +291,7 @@ def webui_chat_backend_mode(config_data=None, environ: dict[str, str] | None = N
|
||||
).strip().lower()
|
||||
if raw in _GATEWAY_CHAT_BACKENDS:
|
||||
return "gateway"
|
||||
return "legacy"
|
||||
return WEBUI_LOCAL_CHAT_BACKEND
|
||||
|
||||
|
||||
def webui_gateway_chat_enabled(config_data=None, environ: dict[str, str] | None = None) -> bool:
|
||||
@@ -561,30 +580,72 @@ def _gateway_runs_approval_event(payload: dict) -> dict | None:
|
||||
}
|
||||
|
||||
|
||||
def _relay_gateway_run_approval(session_id, run_id, payload, base_url, api_key, *, put_gateway_event) -> None:
|
||||
"""Auto-approve or surface one runs-API approval request as a WebUI approval card."""
|
||||
approval_data = _gateway_runs_approval_event(payload)
|
||||
if not approval_data:
|
||||
return
|
||||
approval_data["run_id"] = run_id
|
||||
from api.config import gateway_supports_approval_identity_v1
|
||||
identity_v1 = bool(approval_data.get("_gateway_raw_approval_id_present")) and gateway_supports_approval_identity_v1(base_url, api_key)
|
||||
approval_data["_gateway_agent_identity_v1"] = identity_v1
|
||||
auto_approved, head, total = _settle_gateway_run_approval(
|
||||
session_id,
|
||||
approval_data,
|
||||
base_url,
|
||||
api_key,
|
||||
)
|
||||
if not auto_approved:
|
||||
put_gateway_event("approval", {**(head or approval_data), "pending_count": total})
|
||||
|
||||
|
||||
def _admit_gateway_run(url_runs, headers, run_body, stream_id) -> str:
|
||||
"""POST /v1/runs; the same stream and body return the originally admitted run id."""
|
||||
req = urllib.request.Request(
|
||||
url_runs,
|
||||
data=json.dumps(run_body).encode("utf-8"),
|
||||
# Durable run record on the gateway: GET /v1/runs/{id} survives either side restarting.
|
||||
headers={**headers, "Idempotency-Key": f"webui-{stream_id}"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
run_data = json.loads(resp.read(65536))
|
||||
run_id = str(run_data.get("run_id") or run_data.get("id") or "").strip()
|
||||
if not run_id:
|
||||
raise ValueError(f"Gateway runs API returned no run_id: {run_data!r}")
|
||||
return run_id
|
||||
|
||||
|
||||
def _gateway_run_headers(session_id, api_key) -> dict:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-Hermes-Session-Id": session_id,
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
headers["X-Hermes-Session-Key"] = f"webui:{session_id}"
|
||||
return headers
|
||||
|
||||
|
||||
def _run_gateway_runs_api_streaming(
|
||||
session_id, msg_text, model, workspace, stream_id,
|
||||
base_url, api_key, prefill_messages, body_extras,
|
||||
*, put_gateway_event, cancel_event,
|
||||
attachments=None, cfg=None, session=None,
|
||||
active_provider: str = "",
|
||||
on_run_id=None,
|
||||
):
|
||||
"""Submit via POST /v1/runs and relay SSE events including approval."""
|
||||
try:
|
||||
url_runs = f"{base_url.rstrip('/')}/v1/runs"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-Hermes-Session-Id": session_id,
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
headers["X-Hermes-Session-Key"] = f"webui:{session_id}"
|
||||
headers = _gateway_run_headers(session_id, api_key)
|
||||
message_content: Any = str(msg_text or "")
|
||||
if attachments:
|
||||
try:
|
||||
from api.streaming import _build_native_multimodal_message
|
||||
|
||||
message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg, active_provider=active_provider, active_model=(model or ""), requested_provider=active_provider)
|
||||
message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg, active_provider=active_provider, active_model=(model or ""), requested_provider=active_provider, profile=getattr(session, "profile", None))
|
||||
except Exception:
|
||||
logger.debug("Failed to build runs-API multimodal attachment payload", exc_info=True)
|
||||
message_content = str(msg_text or "")
|
||||
@@ -631,24 +692,19 @@ def _run_gateway_runs_api_streaming(
|
||||
run_body["instructions"] = "\n\n".join(part for part in instructions_parts if part)
|
||||
if conversation_history:
|
||||
run_body["conversation_history"] = conversation_history
|
||||
req = urllib.request.Request(
|
||||
url_runs,
|
||||
data=json.dumps(run_body).encode("utf-8"),
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
update_active_run(stream_id, phase="gateway-request")
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
run_data = json.loads(resp.read(65536))
|
||||
run_id = str(run_data.get("run_id") or run_data.get("id") or "").strip()
|
||||
if not run_id:
|
||||
raise ValueError(f"Gateway runs API returned no run_id: {run_data!r}")
|
||||
# Persist the exact body first: a restart before the run id is saved replays this admission.
|
||||
if on_run_id is not None:
|
||||
on_run_id("", request=run_body)
|
||||
run_id = _admit_gateway_run(url_runs, headers, run_body, stream_id)
|
||||
except Exception:
|
||||
_finish_gateway_run_starting(stream_id)
|
||||
raise
|
||||
|
||||
usage: dict = {}
|
||||
_publish_gateway_run_id(stream_id, run_id)
|
||||
if on_run_id is not None:
|
||||
on_run_id(run_id)
|
||||
|
||||
url_events = f"{base_url.rstrip('/')}/v1/runs/{run_id}/events"
|
||||
headers_sse = dict(headers)
|
||||
@@ -679,22 +735,10 @@ def _run_gateway_runs_api_streaming(
|
||||
continue
|
||||
payload_event = str(payload.get("event") or payload.get("type") or sse_event).strip() or "message"
|
||||
if payload_event == "approval.request":
|
||||
approval_data = _gateway_runs_approval_event(payload)
|
||||
if approval_data:
|
||||
approval_data["run_id"] = run_id
|
||||
from api.config import gateway_supports_approval_identity_v1
|
||||
identity_v1 = bool(approval_data.get("_gateway_raw_approval_id_present")) and gateway_supports_approval_identity_v1(base_url, api_key)
|
||||
approval_data["_gateway_agent_identity_v1"] = identity_v1
|
||||
auto_approved, head, total = _settle_gateway_run_approval(
|
||||
session_id,
|
||||
approval_data,
|
||||
base_url,
|
||||
api_key,
|
||||
)
|
||||
if auto_approved:
|
||||
sse_event = "message"
|
||||
continue
|
||||
put_gateway_event("approval", {**(head or approval_data), "pending_count": total})
|
||||
_relay_gateway_run_approval(
|
||||
session_id, run_id, payload, base_url, api_key,
|
||||
put_gateway_event=put_gateway_event,
|
||||
)
|
||||
sse_event = "message"
|
||||
continue
|
||||
if payload_event in {"tool.started", "tool.completed", "reasoning.available"}:
|
||||
@@ -738,8 +782,12 @@ def _run_gateway_runs_api_streaming(
|
||||
sse_event = "message"
|
||||
continue
|
||||
if payload_event == "run.completed":
|
||||
from api.route_approvals import retire_gateway_pending_mirror
|
||||
retire_gateway_pending_mirror(session_id, run_id=run_id)
|
||||
from api.route_approvals import settle_gateway_pending_run
|
||||
settle_gateway_pending_run(
|
||||
session_id,
|
||||
run_id,
|
||||
reason="Gateway run completed before approval resolution",
|
||||
)
|
||||
if payload.get("error"):
|
||||
raise RuntimeError(str(payload["error"]))
|
||||
output = str(payload.get("output") or "")
|
||||
@@ -751,12 +799,20 @@ def _run_gateway_runs_api_streaming(
|
||||
sse_event = "message"
|
||||
continue
|
||||
if payload_event == "run.failed":
|
||||
from api.route_approvals import retire_gateway_pending_mirror
|
||||
retire_gateway_pending_mirror(session_id, run_id=run_id)
|
||||
from api.route_approvals import settle_gateway_pending_run
|
||||
settle_gateway_pending_run(
|
||||
session_id,
|
||||
run_id,
|
||||
reason="Gateway run failed before approval resolution",
|
||||
)
|
||||
raise RuntimeError(str(payload.get("error") or "Gateway run failed"))
|
||||
if payload_event == "run.cancelled":
|
||||
from api.route_approvals import retire_gateway_pending_mirror
|
||||
retire_gateway_pending_mirror(session_id, run_id=run_id)
|
||||
from api.route_approvals import settle_gateway_pending_run
|
||||
settle_gateway_pending_run(
|
||||
session_id,
|
||||
run_id,
|
||||
reason="Gateway run was cancelled before approval resolution",
|
||||
)
|
||||
put_gateway_event("cancel", {"message": "Cancelled by gateway"})
|
||||
return None, usage
|
||||
reasoning_delta = _gateway_sse_reasoning_delta(payload)
|
||||
@@ -779,11 +835,7 @@ def stop_gateway_run(run_id: str) -> bool:
|
||||
run_id = str(run_id or "").strip()
|
||||
if not run_id:
|
||||
return False
|
||||
from api.config import get_config
|
||||
|
||||
cfg = get_config()
|
||||
base_url = _gateway_base_url(cfg)
|
||||
api_key = _gateway_api_key()
|
||||
base_url, api_key = gateway_run_endpoint(run_id)
|
||||
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
@@ -808,6 +860,180 @@ def stop_gateway_run(run_id: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
_GATEWAY_RUN_TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled", "interrupted"})
|
||||
GATEWAY_REATTACH_POLL_INTERVAL = 2.0
|
||||
# Consecutive unreachable polls tolerated before the reattached turn is failed (~5 min at 2s).
|
||||
GATEWAY_REATTACH_MAX_POLL_FAILURES = 150
|
||||
_REATTACH_SCAN_HEAD_BYTES = 16 * 1024
|
||||
|
||||
|
||||
def _record_gateway_run(session_id: str, stream_id: str, run_id: str, request=None, **extra) -> None:
|
||||
"""Persist the run id (or, before admission, the exact request) so a restarted WebUI can reattach."""
|
||||
try:
|
||||
with _get_session_agent_lock(session_id):
|
||||
session = get_session(session_id)
|
||||
if not _stream_writeback_is_current(session, stream_id):
|
||||
return
|
||||
session.gateway_run = {"run_id": run_id, "stream_id": stream_id, **extra}
|
||||
if not run_id:
|
||||
session.gateway_run["request"] = request
|
||||
session.save(touch_updated_at=False)
|
||||
except Exception:
|
||||
logger.warning("Failed to persist gateway run %s for session %s", run_id, session_id, exc_info=True)
|
||||
if not run_id:
|
||||
raise # never admit a run a restart could not recover
|
||||
|
||||
|
||||
def _get_gateway_run_status(base_url: str, api_key: str, run_id: str) -> dict:
|
||||
headers = {"Accept": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
req = urllib.request.Request(
|
||||
f"{base_url.rstrip('/')}/v1/runs/{urllib.parse.quote(run_id, safe='')}",
|
||||
headers=headers,
|
||||
method="GET",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
payload = json.loads(resp.read(1024 * 1024) or b"{}")
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _await_gateway_run_result(
|
||||
session_id, stream_id, run_id, base_url, api_key,
|
||||
*, put_gateway_event, cancel_event,
|
||||
):
|
||||
"""Poll a run started by a previous WebUI process; same return contract as _run_gateway_runs_api_streaming."""
|
||||
from api.route_approvals import settle_gateway_pending_run
|
||||
|
||||
_publish_gateway_run_id(stream_id, run_id)
|
||||
update_active_run(stream_id, phase="gateway-reattached")
|
||||
surfaced_approval_ids: set[str] = set()
|
||||
failures = 0
|
||||
while True:
|
||||
if cancel_event.is_set():
|
||||
put_gateway_event("cancel", {"message": "Cancelled by user"})
|
||||
return None, {}
|
||||
try:
|
||||
status = _get_gateway_run_status(base_url, api_key, run_id)
|
||||
failures = 0
|
||||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||
code = getattr(exc, "code", None)
|
||||
if code == 404:
|
||||
raise RuntimeError("Gateway no longer has this run; its result could not be recovered after the WebUI restart.") from exc
|
||||
if code in (401, 403):
|
||||
raise RuntimeError(f"Gateway rejected the WebUI credentials (HTTP {code}) while reattaching to a run after the restart.") from exc
|
||||
failures += 1
|
||||
if failures >= GATEWAY_REATTACH_MAX_POLL_FAILURES:
|
||||
raise RuntimeError("Gateway became unreachable while waiting for a run that outlived the WebUI restart.") from exc
|
||||
cancel_event.wait(GATEWAY_REATTACH_POLL_INTERVAL)
|
||||
continue
|
||||
state = str(status.get("status") or "").strip().lower()
|
||||
approval = status.get("approval")
|
||||
if state == "waiting_for_approval" and isinstance(approval, dict):
|
||||
approval_key = str(approval.get("approval_id") or approval.get("id") or approval.get("timestamp") or "")
|
||||
if approval_key not in surfaced_approval_ids:
|
||||
surfaced_approval_ids.add(approval_key)
|
||||
_relay_gateway_run_approval(
|
||||
session_id, run_id, approval, base_url, api_key, put_gateway_event=put_gateway_event,
|
||||
)
|
||||
if state in _GATEWAY_RUN_TERMINAL_STATUSES:
|
||||
settle_gateway_pending_run(session_id, run_id, reason=f"Gateway run {state} before approval resolution")
|
||||
if state == "cancelled":
|
||||
put_gateway_event("cancel", {"message": "Cancelled by gateway"})
|
||||
return None, {}
|
||||
if state != "completed":
|
||||
raise RuntimeError(str(status.get("error") or f"Gateway run {state}"))
|
||||
output = str(status.get("output") or "")
|
||||
if output and stream_id in STREAM_PARTIAL_TEXT:
|
||||
STREAM_PARTIAL_TEXT[stream_id] = output
|
||||
return output, {k: v for k, v in _gateway_stream_usage(status).items() if v}
|
||||
cancel_event.wait(GATEWAY_REATTACH_POLL_INTERVAL)
|
||||
|
||||
|
||||
def resume_gateway_runs_after_restart() -> list[str]:
|
||||
"""Reattach pending turns whose gateway run outlived the previous WebUI process; never raises.
|
||||
|
||||
Call before serving so stale-pending repair does not mark these turns interrupted.
|
||||
"""
|
||||
from api import models as _models
|
||||
|
||||
try:
|
||||
candidates = _sidecars_with_active_stream(_models.SESSION_DIR)
|
||||
except Exception:
|
||||
logger.warning("gateway reattach: could not scan session sidecars", exc_info=True)
|
||||
return []
|
||||
resumed: list[str] = []
|
||||
for sid in candidates:
|
||||
try:
|
||||
if _resume_gateway_run_for_session(_models.Session.load_metadata_only(sid)):
|
||||
resumed.append(sid)
|
||||
except Exception:
|
||||
logger.warning("gateway reattach failed for session %s", sid, exc_info=True)
|
||||
if resumed:
|
||||
logger.info("Reattached %d gateway run(s) that outlived the previous WebUI process", len(resumed))
|
||||
return resumed
|
||||
|
||||
|
||||
def _sidecars_with_active_stream(session_dir) -> list[str]:
|
||||
"""Ids of sidecars (not the index, which can lag a save) whose header shows an active stream."""
|
||||
ids = []
|
||||
for path in sorted(session_dir.glob("*.json")):
|
||||
if path.name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
with path.open("rb") as fp:
|
||||
head = fp.read(_REATTACH_SCAN_HEAD_BYTES)
|
||||
except OSError:
|
||||
continue
|
||||
if b'"active_stream_id": null' not in head:
|
||||
ids.append(path.stem)
|
||||
return ids
|
||||
|
||||
|
||||
def _gateway_endpoint_for_profile(profile_name) -> tuple[str, str]:
|
||||
"""URL and key of the session's own profile, root included, never the process-active profile."""
|
||||
from api import profiles as _profiles
|
||||
from api.config import get_config_for_profile_home
|
||||
|
||||
home = _profiles.get_hermes_home_for_profile(str(profile_name or "").strip())
|
||||
environ = {k: v for k, v in os.environ.items() if k not in _profiles._loaded_profile_env_keys}
|
||||
environ.update(_profiles.filter_runtime_env_for_gateway_parity(_profiles.get_profile_runtime_env(home)))
|
||||
return _gateway_base_url(get_config_for_profile_home(home), environ), _gateway_api_key(environ)
|
||||
|
||||
|
||||
def _resume_gateway_run_for_session(session) -> bool:
|
||||
from api.config import create_stream_channel, register_session_writeback_owner, register_stream_owner
|
||||
|
||||
run = (session.gateway_run if session is not None else None) or {}
|
||||
stream_id = str(run.get("stream_id") or "")
|
||||
# A persisted run is the authority whatever the process default backend is.
|
||||
if not stream_id or not (run.get("run_id") or run.get("request")) or session.active_stream_id != stream_id:
|
||||
return False
|
||||
sid = session.session_id
|
||||
endpoint = _gateway_endpoint_for_profile(session.profile)
|
||||
with STREAMS_LOCK:
|
||||
if stream_id in STREAMS:
|
||||
return False
|
||||
STREAMS[stream_id] = create_stream_channel()
|
||||
register_stream_owner(stream_id, sid)
|
||||
register_session_writeback_owner(sid, stream_id)
|
||||
_mark_gateway_run_starting(stream_id)
|
||||
threading.Thread(
|
||||
target=_run_gateway_chat_streaming,
|
||||
args=(sid, session.pending_user_message or "", session.model, session.workspace,
|
||||
stream_id, list(session.pending_attachments or [])),
|
||||
kwargs={
|
||||
"model_provider": session.model_provider,
|
||||
"goal_related": bool(run.get("goal_related")),
|
||||
"regeneration": bool(run.get("regeneration")),
|
||||
"reattach_run": run,
|
||||
"reattach_endpoint": endpoint,
|
||||
},
|
||||
name=f"gateway-reattach-{stream_id[:12]}",
|
||||
daemon=True,
|
||||
).start()
|
||||
return True
|
||||
|
||||
def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, model_provider, terminal_error):
|
||||
from api.streaming import (
|
||||
_classify_provider_error,
|
||||
@@ -831,6 +1057,7 @@ def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, mode
|
||||
turn_duration = _terminal_turn_duration(session)
|
||||
_materialize_pending_user_turn_before_error(session)
|
||||
session.active_stream_id = None
|
||||
session.gateway_run = None
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = []
|
||||
session.pending_started_at = None
|
||||
@@ -874,14 +1101,32 @@ def _settle_gateway_terminal_error(session_id, stream_id, workspace, model, mode
|
||||
return error_payload
|
||||
|
||||
|
||||
def _settle_gateway_cancelled_turn(session_id, stream_id) -> None:
|
||||
"""Keep the prompt and a cancel marker when the run ended cancelled and Stop did not already settle it."""
|
||||
from api.streaming import _persist_cancelled_turn
|
||||
|
||||
with _get_session_agent_lock(session_id):
|
||||
session = get_session(session_id)
|
||||
if not _stream_writeback_is_current(session, stream_id):
|
||||
return
|
||||
_persist_cancelled_turn(session, message="Cancelled by gateway")
|
||||
session.gateway_run = None
|
||||
session.save()
|
||||
|
||||
|
||||
def _stream_writeback_is_current(session: Any, stream_id: str) -> bool:
|
||||
return bool(stream_id and getattr(session, "active_stream_id", None) == stream_id)
|
||||
|
||||
|
||||
def _clear_gateway_pending_state(session: Any, stream_id: str) -> None:
|
||||
if not _stream_writeback_is_current(session, stream_id):
|
||||
# Cancel clears active_stream_id eagerly; still drop this stream's run record.
|
||||
if session is not None and (session.gateway_run or {}).get("stream_id") == stream_id:
|
||||
session.gateway_run = None
|
||||
session.save(touch_updated_at=False)
|
||||
return
|
||||
session.active_stream_id = None
|
||||
session.gateway_run = None
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = None
|
||||
session.pending_started_at = None
|
||||
@@ -911,6 +1156,8 @@ def _run_gateway_chat_streaming(
|
||||
model_provider=None,
|
||||
goal_related=False,
|
||||
regeneration=False,
|
||||
reattach_run=None,
|
||||
reattach_endpoint=None,
|
||||
):
|
||||
"""Bridge a WebUI chat turn through Hermes Gateway's API server.
|
||||
|
||||
@@ -978,7 +1225,7 @@ def _run_gateway_chat_streaming(
|
||||
except Exception:
|
||||
logger.debug("Failed to note gateway event_id %s for stream %s", event_id, stream_id, exc_info=True)
|
||||
try:
|
||||
queue_item = (event, data, event_id) if event_id and hasattr(q, "subscribe_with_snapshot") else (event, data)
|
||||
queue_item = (event, data, event_id) if hasattr(q, "subscribe_with_snapshot") else (event, data)
|
||||
q.put_nowait(queue_item)
|
||||
except Exception:
|
||||
logger.debug("Failed to put gateway event to queue")
|
||||
@@ -997,8 +1244,9 @@ def _run_gateway_chat_streaming(
|
||||
model=model,
|
||||
model_provider=model_provider,
|
||||
)
|
||||
base_url = _gateway_base_url(cfg)
|
||||
api_key = _gateway_api_key()
|
||||
base_url, api_key = reattach_endpoint or (_gateway_base_url(cfg), _gateway_api_key())
|
||||
with _STREAM_RUN_STARTING_CONDITION:
|
||||
_STREAM_ENDPOINTS[stream_id] = (base_url, api_key)
|
||||
try:
|
||||
from api.config import _main_model_request_overrides
|
||||
_gw_overrides = _main_model_request_overrides(
|
||||
@@ -1009,7 +1257,7 @@ def _run_gateway_chat_streaming(
|
||||
except Exception:
|
||||
_gw_overrides = {}
|
||||
_runs_api_enabled = _gateway_use_runs_api_enabled(cfg)
|
||||
_use_runs_api = _runs_api_enabled and gateway_supports_approval(base_url, api_key)
|
||||
_use_runs_api = bool(reattach_run) or (_runs_api_enabled and gateway_supports_approval(base_url, api_key))
|
||||
if not _use_runs_api and runs_api_pending_marked:
|
||||
_finish_gateway_run_starting(stream_id, result="fallback")
|
||||
runs_api_pending_marked = False
|
||||
@@ -1059,17 +1307,37 @@ def _run_gateway_chat_streaming(
|
||||
body_extras["reasoning_effort"] = reasoning_effort
|
||||
if _gw_overrides.get("service_tier"):
|
||||
body_extras["service_tier"] = _gw_overrides["service_tier"]
|
||||
record_run = lambda run_id, request=None: _record_gateway_run(
|
||||
session_id, stream_id, run_id, request=request,
|
||||
regeneration=bool(regeneration), goal_related=bool(goal_related),
|
||||
)
|
||||
try:
|
||||
final_text, usage = _run_gateway_runs_api_streaming(
|
||||
session_id, msg_text, model, workspace, stream_id,
|
||||
base_url, api_key, prefill_messages, body_extras,
|
||||
put_gateway_event=put_gateway_event,
|
||||
cancel_event=cancel_event,
|
||||
attachments=attachments,
|
||||
cfg=cfg,
|
||||
session=s,
|
||||
active_provider=(model_provider or ""),
|
||||
)
|
||||
if reattach_run:
|
||||
run_id = str(reattach_run.get("run_id") or "").strip()
|
||||
if not run_id:
|
||||
# Crashed between admission and saving the id: the same key returns the original run.
|
||||
run_id = _admit_gateway_run(
|
||||
f"{base_url.rstrip('/')}/v1/runs", _gateway_run_headers(session_id, api_key),
|
||||
reattach_run["request"], stream_id,
|
||||
)
|
||||
record_run(run_id)
|
||||
final_text, usage = _await_gateway_run_result(
|
||||
session_id, stream_id, run_id, base_url, api_key,
|
||||
put_gateway_event=put_gateway_event,
|
||||
cancel_event=cancel_event,
|
||||
)
|
||||
else:
|
||||
final_text, usage = _run_gateway_runs_api_streaming(
|
||||
session_id, msg_text, model, workspace, stream_id,
|
||||
base_url, api_key, prefill_messages, body_extras,
|
||||
put_gateway_event=put_gateway_event,
|
||||
cancel_event=cancel_event,
|
||||
attachments=attachments,
|
||||
cfg=cfg,
|
||||
session=s,
|
||||
active_provider=(model_provider or ""),
|
||||
on_run_id=record_run,
|
||||
)
|
||||
except Exception as exc:
|
||||
error_payload = _settle_gateway_terminal_error(
|
||||
session_id,
|
||||
@@ -1084,6 +1352,7 @@ def _run_gateway_chat_streaming(
|
||||
put_gateway_event("apperror", error_payload)
|
||||
return
|
||||
if final_text is None:
|
||||
_settle_gateway_cancelled_turn(session_id, stream_id)
|
||||
return
|
||||
else:
|
||||
# Legacy gateway path: emit unsupported approval notice once per session,
|
||||
@@ -1120,7 +1389,7 @@ def _run_gateway_chat_streaming(
|
||||
try:
|
||||
from api.streaming import _build_native_multimodal_message
|
||||
|
||||
message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg, active_provider=(model_provider or ""), active_model=(model or ""), requested_provider=(model_provider or ""))
|
||||
message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg, active_provider=(model_provider or ""), active_model=(model or ""), requested_provider=(model_provider or ""), profile=getattr(s, "profile", None))
|
||||
except Exception:
|
||||
logger.debug("Failed to build gateway multimodal attachment payload", exc_info=True)
|
||||
message_content = str(msg_text or "")
|
||||
@@ -1282,7 +1551,11 @@ def _run_gateway_chat_streaming(
|
||||
# role/content ordering instead of turn order.
|
||||
assistant_ts = now + 0.000001
|
||||
pending_source = getattr(s, "pending_user_source", None) or "webui"
|
||||
from api.streaming import _active_turn_authority, _materialize_active_turn_user
|
||||
from api.streaming import (
|
||||
_active_turn_authority,
|
||||
_active_turn_token_matches,
|
||||
_materialize_active_turn_user,
|
||||
)
|
||||
|
||||
active_turn_identity = _active_turn_authority(s, stream_id, msg_text)
|
||||
user_msg = _materialize_active_turn_user(
|
||||
@@ -1343,6 +1616,9 @@ def _run_gateway_chat_streaming(
|
||||
s.context_messages,
|
||||
str(msg_text or ""),
|
||||
source=pending_source,
|
||||
verification_nudge_provenance={
|
||||
"active_turn_identity": active_turn_identity,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to merge gateway display transcript", exc_info=True)
|
||||
@@ -1355,7 +1631,16 @@ def _run_gateway_chat_streaming(
|
||||
if latest_text == msg_norm:
|
||||
display = display[:-1]
|
||||
s.messages = display + [user_msg, assistant_msg]
|
||||
if active_turn_identity.get("token"):
|
||||
current_display_rows = [
|
||||
message
|
||||
for message in s.messages
|
||||
if _active_turn_token_matches(message, active_turn_identity)
|
||||
]
|
||||
if len(current_display_rows) == 1:
|
||||
current_display_rows[0]["timestamp"] = user_msg["timestamp"]
|
||||
s.active_stream_id = None
|
||||
s.gateway_run = None
|
||||
s.pending_user_message = None
|
||||
s.pending_attachments = None
|
||||
s.pending_started_at = None
|
||||
@@ -1389,6 +1674,21 @@ def _run_gateway_chat_streaming(
|
||||
if cancel_event.is_set():
|
||||
_restore_cancelled_success_writeback()
|
||||
return
|
||||
# #6366 re-gate: record the durable same-stream completion
|
||||
# event in the crash-safe turn journal. The run journal's
|
||||
# terminal state is only reached on its own ``stream_end``
|
||||
# write path, so a Gateway run whose terminal write is lost
|
||||
# would otherwise leave no completion evidence at all and
|
||||
# stale-cancel recovery would re-append a duplicate
|
||||
# recovered row after the valid final answer.
|
||||
try:
|
||||
append_turn_journal_event_for_stream(
|
||||
session_id,
|
||||
stream_id,
|
||||
{"event": "completed", "created_at": time.time()},
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to append completed turn journal event", exc_info=True)
|
||||
success_writeback_committed = True
|
||||
try:
|
||||
from api.goals import evaluate_goal_after_turn, has_active_goal
|
||||
@@ -1464,10 +1764,14 @@ def _run_gateway_chat_streaming(
|
||||
mapped_run_id = str(_STREAM_RUN_IDS.get(stream_id) or "").strip()
|
||||
if mapped_run_id:
|
||||
try:
|
||||
from api.route_approvals import retire_gateway_pending_mirror
|
||||
retire_gateway_pending_mirror(session_id, run_id=mapped_run_id)
|
||||
from api.route_approvals import settle_gateway_pending_run
|
||||
settle_gateway_pending_run(
|
||||
session_id,
|
||||
mapped_run_id,
|
||||
reason="Gateway run ended during teardown before approval resolution",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to retire gateway pending mirrors during teardown", exc_info=True)
|
||||
logger.debug("Failed to settle gateway pending approvals during teardown", exc_info=True)
|
||||
if s is not None:
|
||||
try:
|
||||
with _get_session_agent_lock(session_id):
|
||||
@@ -1487,6 +1791,8 @@ def _run_gateway_chat_streaming(
|
||||
if runs_api_pending_marked and gateway_run_id_pending(stream_id):
|
||||
_finish_gateway_run_starting(stream_id)
|
||||
_clear_gateway_run_starting(stream_id)
|
||||
with _STREAM_RUN_STARTING_CONDITION:
|
||||
_STREAM_ENDPOINTS.pop(stream_id, None)
|
||||
unregister_stream_owner(stream_id)
|
||||
unregister_active_run(stream_id)
|
||||
# Release the writeback-owner entry the route layer registered for this
|
||||
|
||||
+98
-36
@@ -9,6 +9,7 @@ This enables real-time session list updates in the sidebar without
|
||||
requiring any changes to hermes-agent.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
@@ -27,12 +28,27 @@ logger = logging.getLogger(__name__)
|
||||
# ── State hash tracking ─────────────────────────────────────────────────────
|
||||
|
||||
def _snapshot_hash(sessions: list) -> str:
|
||||
"""Create a lightweight hash of session IDs and timestamps for change detection."""
|
||||
key = '|'.join(
|
||||
f"{s['session_id']}:{s.get('updated_at', 0)}:{s.get('message_count', 0)}"
|
||||
for s in sorted(sessions, key=lambda x: x['session_id'])
|
||||
)
|
||||
return hashlib.md5(key.encode(), usedforsecurity=False).hexdigest()
|
||||
"""Hash the complete published session payload for change detection.
|
||||
|
||||
Every emitted field participates, not only the id / activity timestamp /
|
||||
message count triple. Projection authority (compression collapse,
|
||||
``model_config`` lineage markers, title promotion) can change a row's
|
||||
``title``, ``created_at`` or source metadata while that triple stays fixed;
|
||||
hashing only the triple let the projection rerun, keep ``_last_sessions``
|
||||
stale and emit no ``sessions_changed`` event. Entries are canonicalised
|
||||
(sorted keys, stable separators) and ordered by ``session_id`` so the hash
|
||||
is deterministic and independent of sidebar ordering; cost is bounded by
|
||||
the projection's own row limit.
|
||||
"""
|
||||
digest = hashlib.md5(usedforsecurity=False)
|
||||
for session in sorted(sessions, key=lambda x: str(x.get('session_id') or '')):
|
||||
digest.update(
|
||||
json.dumps(
|
||||
session, sort_keys=True, separators=(',', ':'), default=str
|
||||
).encode('utf-8', 'replace')
|
||||
)
|
||||
digest.update(b'\x1e')
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
# Sources excluded from the WebUI sidebar projection. Must match the default
|
||||
@@ -70,10 +86,10 @@ def _cheap_change_fingerprint(db_path: Path) -> str | None:
|
||||
# are always present (``source`` is required for the projection to run at
|
||||
# all); the rest are optional on older agent schemas and filtered below.
|
||||
_PROJECTION_SESSION_COLS = (
|
||||
'id', 'source', 'session_source', 'title', 'model', 'message_count',
|
||||
'started_at', 'ended_at', 'end_reason', 'parent_session_id', 'archived',
|
||||
'user_id', 'chat_id', 'chat_type', 'thread_id', 'session_key',
|
||||
'origin_chat_id', 'origin_user_id', 'platform',
|
||||
'id', 'source', 'session_source', 'model_config', 'title', 'model',
|
||||
'message_count', 'started_at', 'ended_at', 'end_reason',
|
||||
'parent_session_id', 'archived', 'user_id', 'chat_id', 'chat_type',
|
||||
'thread_id', 'session_key', 'origin_chat_id', 'origin_user_id', 'platform',
|
||||
)
|
||||
try:
|
||||
with closing(open_state_db_readonly(db_path)) as conn:
|
||||
@@ -203,7 +219,11 @@ class GatewayWatcher:
|
||||
):
|
||||
self._subscribers: list[queue.Queue] = []
|
||||
self._sub_lock = threading.Lock()
|
||||
# Final removal invalidates any projection admitted by an earlier cohort.
|
||||
self._subscriber_epoch = 0
|
||||
self._stop_event = threading.Event()
|
||||
# Wakes a poll loop parked because nobody is subscribed (subscribe/stop).
|
||||
self._idle_wake = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._hermes_home = Path(hermes_home).expanduser().resolve() if hermes_home else None
|
||||
self._state_db_path = (
|
||||
@@ -244,6 +264,7 @@ class GatewayWatcher:
|
||||
def stop(self):
|
||||
"""Stop the watcher thread."""
|
||||
self._stop_event.set()
|
||||
self._idle_wake.set() # unpark if waiting for the first subscriber
|
||||
# Wake up any subscribers
|
||||
with self._sub_lock:
|
||||
for q in self._subscribers:
|
||||
@@ -255,6 +276,11 @@ class GatewayWatcher:
|
||||
self._thread.join(timeout=3)
|
||||
self._thread = None
|
||||
|
||||
def _has_subscribers(self) -> bool:
|
||||
"""Return True when at least one SSE client is attached."""
|
||||
with self._sub_lock:
|
||||
return bool(self._subscribers)
|
||||
|
||||
def subscribe(self) -> queue.Queue:
|
||||
"""Subscribe to change events. Returns a queue.Queue.
|
||||
Events are dicts: {'type': 'sessions_changed', 'sessions': [...]}
|
||||
@@ -273,23 +299,37 @@ class GatewayWatcher:
|
||||
q.put_nowait(None)
|
||||
except Exception:
|
||||
logger.debug("Failed to send stop sentinel to late subscriber")
|
||||
# Wake a poll loop parked with zero subscribers so the first SSE client
|
||||
# gets a prompt initial projection instead of waiting out POLL_INTERVAL.
|
||||
self._idle_wake.set()
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: queue.Queue):
|
||||
"""Remove a subscriber queue."""
|
||||
with self._sub_lock:
|
||||
try:
|
||||
self._subscribers.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
def _remove_subscriber_locked(self, q: queue.Queue) -> bool:
|
||||
"""Remove a known queue under _sub_lock; fence polls on the last removal."""
|
||||
try:
|
||||
self._subscribers.remove(q)
|
||||
except ValueError:
|
||||
return False
|
||||
if not self._subscribers:
|
||||
self._subscriber_epoch += 1
|
||||
self._last_cheap_fp = ''
|
||||
self._last_full_projection_at = None
|
||||
return True
|
||||
|
||||
def _notify_subscribers(self, sessions: list):
|
||||
def unsubscribe(self, q: queue.Queue):
|
||||
"""Remove a subscriber queue, invalidating projection on the last removal."""
|
||||
with self._sub_lock:
|
||||
self._remove_subscriber_locked(q)
|
||||
|
||||
def _notify_subscribers(self, sessions: list, *, epoch: int | None = None):
|
||||
"""Push change event to all subscribers."""
|
||||
event = {
|
||||
'type': 'sessions_changed',
|
||||
'sessions': sessions,
|
||||
}
|
||||
with self._sub_lock:
|
||||
if epoch is not None and epoch != self._subscriber_epoch:
|
||||
return # An old poll must not notify a new subscriber cohort.
|
||||
dead = []
|
||||
for q in self._subscribers:
|
||||
try:
|
||||
@@ -299,10 +339,7 @@ class GatewayWatcher:
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
try:
|
||||
self._subscribers.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
self._remove_subscriber_locked(q)
|
||||
# Send a None sentinel so the SSE handler unblocks, closes,
|
||||
# and lets the browser's EventSource auto-reconnect.
|
||||
try:
|
||||
@@ -317,6 +354,12 @@ class GatewayWatcher:
|
||||
protects projection fields (notably role-derived CLI visibility) that
|
||||
the agent's existing index cannot see.
|
||||
"""
|
||||
with self._sub_lock:
|
||||
has_subscribers = bool(self._subscribers)
|
||||
admission_epoch = self._subscriber_epoch
|
||||
if not has_subscribers:
|
||||
# Final removal already invalidated the cache under the same lock.
|
||||
return False
|
||||
db_path = self._state_db_path
|
||||
# A watcher may start before the agent has created state.db. Publishing an
|
||||
# empty first snapshot would make an already-rendered sidebar disappear;
|
||||
@@ -344,29 +387,48 @@ class GatewayWatcher:
|
||||
if sessions is None:
|
||||
return False
|
||||
current_hash = _snapshot_hash(sessions)
|
||||
if cheap_fp is not None:
|
||||
self._last_cheap_fp = cheap_fp
|
||||
self._last_full_projection_at = current_time
|
||||
|
||||
if current_hash != self._last_hash:
|
||||
self._last_hash = current_hash
|
||||
self._last_sessions = sessions
|
||||
self._notify_subscribers(sessions)
|
||||
with self._sub_lock:
|
||||
if admission_epoch != self._subscriber_epoch or not self._subscribers:
|
||||
return False # Never restore a cache invalidated during the DB read.
|
||||
if cheap_fp is not None:
|
||||
self._last_cheap_fp = cheap_fp
|
||||
self._last_full_projection_at = current_time
|
||||
if current_hash != self._last_hash:
|
||||
changed = True
|
||||
self._last_hash = current_hash
|
||||
self._last_sessions = sessions
|
||||
else:
|
||||
changed = False
|
||||
if changed:
|
||||
self._notify_subscribers(sessions, epoch=admission_epoch)
|
||||
return True
|
||||
|
||||
def _poll_loop(self):
|
||||
"""Main polling loop. Runs in a daemon thread."""
|
||||
"""Main polling loop. Runs in a daemon thread.
|
||||
|
||||
With no SSE subscribers there is nobody to notify, so the loop parks on
|
||||
``_idle_wake`` instead of re-fingerprinting ``state.db`` every few
|
||||
seconds (maint #3035). While subscribed it blocks once on
|
||||
``_stop_event.wait(POLL_INTERVAL)`` — a single timer sleep that returns
|
||||
immediately when ``stop()`` sets the event, replacing the previous
|
||||
10 wakeups/sec ``time.sleep(0.1)`` spin.
|
||||
"""
|
||||
while not self._stop_event.is_set():
|
||||
if not self._has_subscribers():
|
||||
self._idle_wake.clear()
|
||||
# subscribe() may have raced between the check and clear; if so
|
||||
# the flag is already set again and we must not park.
|
||||
if self._has_subscribers() or self._stop_event.is_set():
|
||||
continue
|
||||
self._idle_wake.wait()
|
||||
continue
|
||||
|
||||
try:
|
||||
self._poll_once()
|
||||
except Exception:
|
||||
logger.debug("Error in gateway watcher poll loop", exc_info=True)
|
||||
|
||||
# Sleep in small increments so we can stop promptly
|
||||
for _ in range(self.POLL_INTERVAL * 10):
|
||||
if self._stop_event.is_set():
|
||||
return
|
||||
time.sleep(0.1)
|
||||
self._stop_event.wait(self.POLL_INTERVAL)
|
||||
|
||||
|
||||
# ── Module-level watcher registry ──────────────────────────────────────────
|
||||
|
||||
+413
-5
@@ -9,6 +9,7 @@ import logging
|
||||
import os
|
||||
import re as _re
|
||||
import ssl
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from api.config import IMAGE_EXTS, MD_EXTS
|
||||
|
||||
@@ -23,6 +24,8 @@ _PUBLIC_MESSAGE_INTERNAL_FIELDS = frozenset({
|
||||
"_active_turn_token",
|
||||
"_active_turn_user",
|
||||
"_fork_child_turn",
|
||||
"_webui_trusted_agent_input_text",
|
||||
"_webui_unmatched_native_image_mirror",
|
||||
})
|
||||
|
||||
|
||||
@@ -201,6 +204,248 @@ def _security_headers(handler):
|
||||
)
|
||||
|
||||
|
||||
def arm_connection_close(handler) -> None:
|
||||
"""Mark the connection dead BEFORE a reject-before-read response is written.
|
||||
|
||||
A rejection that answers without consuming the request body leaves those
|
||||
bytes queued in ``rfile``; reusing the connection makes the next HTTP/1.1
|
||||
request parse mid-body (the classic ``{}GET ... -> 501``). Arming must
|
||||
happen before the response is flushed so ``advertise_connection_close()``
|
||||
can serialize it into the response headers.
|
||||
|
||||
Best-effort: handler stubs and read-only doubles may reject the attribute,
|
||||
and a failure here must never turn a 4xx into a 500.
|
||||
"""
|
||||
try:
|
||||
handler.close_connection = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# What ``unsupported_transfer_encoding()`` reports for a header that declares no
|
||||
# coding at all (``Transfer-Encoding:``). A placeholder rather than the raw '' so
|
||||
# the return value is never a falsy non-``None`` — callers test ``is not None``,
|
||||
# and an empty string would also render as a hole in the rejection message.
|
||||
_BLANK_TRANSFER_CODING = '(blank)'
|
||||
|
||||
# The only whitespace a field value may be padded with: RFC 9110 OWS is SP/HTAB
|
||||
# and nothing else. ``str.strip()`` would also erase U+00A0 and U+0085 -- both
|
||||
# reachable over the wire, because request lines decode as latin-1 -- and
|
||||
# ``Content-Length: \xa00`` would then read back as a genuine ``0`` while its
|
||||
# payload sat unread on the socket. Padding a value with a character the grammar
|
||||
# does not allow makes it unreadable, not zero.
|
||||
_FIELD_VALUE_OWS = ' \t'
|
||||
|
||||
|
||||
def _framing_header_values(handler, name: str) -> list[str]:
|
||||
"""Every value the wire carried for one framing header, tolerating stubs.
|
||||
|
||||
ALL values, not just the first: ``Message.get()`` returns only the first
|
||||
occurrence, so a request carrying ``Content-Length: 0`` followed by
|
||||
``Content-Length: 42`` reads back as length zero. Its 42 body bytes are then
|
||||
never consumed and get parsed as the next request line -- the same poisoning
|
||||
this module exists to prevent, reached by duplicating a header rather than by
|
||||
omitting one. Conflicting framing is only visible if every value is.
|
||||
|
||||
A real handler carries an ``email.message.Message`` (case-insensitive
|
||||
``get_all()``, like the wire); the suite's ``SimpleNamespace(headers={...})``
|
||||
doubles are plain dicts, which are not, so fall back to a case-insensitive
|
||||
scan for those. A missing or odd ``headers`` must never raise: every caller
|
||||
is on a rejection path where an exception becomes a spurious 500.
|
||||
"""
|
||||
headers = getattr(handler, 'headers', None)
|
||||
if headers is None:
|
||||
return []
|
||||
try:
|
||||
get_all = getattr(headers, 'get_all', None)
|
||||
if callable(get_all):
|
||||
values = get_all(name)
|
||||
return [] if values is None else [str(v) for v in values if v is not None]
|
||||
if isinstance(headers, dict):
|
||||
lowered = name.lower()
|
||||
return [
|
||||
str(v) for k, v in headers.items()
|
||||
if str(k).lower() == lowered and v is not None
|
||||
]
|
||||
value = headers.get(name)
|
||||
return [] if value is None else [str(value)]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _declared_content_lengths(handler) -> set[int] | None:
|
||||
"""Distinct declared ``Content-Length`` values, or ``None`` if any is unreadable.
|
||||
|
||||
``None`` means the framing cannot be trusted at all (a value that will not
|
||||
parse); an empty set means no length was declared.
|
||||
|
||||
A BLANK or whitespace-only value is unreadable, NOT absent. ``Content-Length:``
|
||||
with payload bytes behind it declares a body whose size the header refuses to
|
||||
state; skipping the empty value reported "no length declared", so the
|
||||
rejection kept the connection alive and those bytes were parsed as the next
|
||||
request line (403 then ``400 Bad request syntax ('{...}GET /api/... HTTP/1.1')``).
|
||||
Absence is something only the wire can say, and ``_framing_header_values()``
|
||||
already reports a header the request never carried as no value at all -- so
|
||||
anything that *is* here and does not parse has to be treated as pending bytes,
|
||||
exactly like ``banana`` or two lengths that disagree. Note ``Message`` collapses
|
||||
``Content-Length:`` and ``Content-Length: `` to the same ``''``.
|
||||
|
||||
"Parses" means RFC 9110's ``Content-Length = 1*DIGIT``: an unsigned run of
|
||||
ASCII digits, nothing else. ``int()`` is far more generous than the grammar --
|
||||
it accepts a leading sign, PEP 515 underscores and non-ASCII digits -- and
|
||||
every such spelling of ZERO used to read back as an honest ``0`` and take the
|
||||
keep-alive branch below with its payload still queued: ``Content-Length: +0``
|
||||
(also ``-0``, ``0_0``, ``+00``, ``\\xa00``) answered 403 with no
|
||||
``Connection: close``, then ``400 Bad request syntax ('{...}GET /api/...')``
|
||||
-- the same trace as a blank value. A sign is only invisible on a NON-zero
|
||||
value, where the wrong parse happens to close anyway. Anything the grammar
|
||||
rejects is unreadable framing, which means a body may be pending.
|
||||
|
||||
The ``int()`` guard stays even though ``isdigit()`` has already vetted every
|
||||
character: a digit run longer than ``sys.get_int_max_str_digits()`` (4300)
|
||||
raises ``ValueError`` too, and this runs on rejection paths where an
|
||||
exception becomes a spurious 500.
|
||||
"""
|
||||
parsed: set[int] = set()
|
||||
for raw in _framing_header_values(handler, 'Content-Length'):
|
||||
# RFC 9110 §5.3: a list-valued field may arrive as repeated lines OR as
|
||||
# one comma-combined line, and the two spellings mean the same thing.
|
||||
# So "0, 0" is the same bodyless request as two "Content-Length: 0"
|
||||
# headers and must keep its keep-alive; only a DISAGREEING list (caught
|
||||
# below by len(parsed) > 1) is unframeable. Without the split the comma
|
||||
# fails isdigit() and every agreeing list over-closed a healthy socket.
|
||||
for member in raw.split(','):
|
||||
stripped = member.strip(_FIELD_VALUE_OWS)
|
||||
if not (stripped.isascii() and stripped.isdigit()):
|
||||
return None
|
||||
try:
|
||||
parsed.add(int(stripped))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def unreadable_content_length(handler) -> bool:
|
||||
"""True when ``Content-Length`` cannot be trusted to frame the body.
|
||||
|
||||
Either a value that will not parse (``banana``, ``0x5``, or a BLANK one), or
|
||||
two or more values that disagree. None of them can tell a reader how many
|
||||
bytes to drain, so such a request can only be rejected -- with the connection
|
||||
armed for close, since bytes may still be queued behind it.
|
||||
"""
|
||||
parsed = _declared_content_lengths(handler)
|
||||
return parsed is None or len(parsed) > 1
|
||||
|
||||
|
||||
def request_declares_body(handler) -> bool:
|
||||
"""True when the request's framing declares a body that is still queued.
|
||||
|
||||
Keyed on the wire, not on the method: ``Transfer-Encoding`` present (chunked
|
||||
framing hides the length until the terminating chunk is read) or
|
||||
``Content-Length`` present and non-zero. The method proves nothing in either
|
||||
direction -- a GET may carry a declared body and a POST/PUT/DELETE may carry
|
||||
none -- and a static per-method flag therefore both misses body-bearing
|
||||
"read" requests (whose unread bytes poison the next pooled request) and
|
||||
kills healthy keep-alive on body-less writes.
|
||||
|
||||
A blank, unparseable or self-contradicting ``Content-Length`` counts as a
|
||||
declared body, as does ANY ``Transfer-Encoding`` -- ``identity`` and a blank
|
||||
value included: a value we cannot interpret does not prove the body's absence,
|
||||
and the safe assumption for connection reuse is that bytes are pending. Only
|
||||
framing that positively says "no body" -- no framing header at all, or every
|
||||
declared length agreeing on zero -- keeps the connection alive.
|
||||
"""
|
||||
if unsupported_transfer_encoding(handler) is not None:
|
||||
return True
|
||||
parsed = _declared_content_lengths(handler)
|
||||
if parsed is None:
|
||||
return True
|
||||
return any(length != 0 for length in parsed)
|
||||
|
||||
|
||||
def unsupported_transfer_encoding(handler) -> str | None:
|
||||
"""Return the transfer coding this server cannot decode, or ``None``.
|
||||
|
||||
EVERY present value counts, with no exemption for any coding.
|
||||
``BaseHTTPRequestHandler`` decodes none of them: it hands every reader a raw
|
||||
``rfile``, so a chunked body reads back as its literal chunk framing while a
|
||||
``Content-Length``-based reader sees length 0 and consumes nothing at all.
|
||||
RFC 9112 section 6.3 says the same thing normatively -- a request whose final
|
||||
transfer coding is not ``chunked`` has a body length the recipient cannot
|
||||
determine, so it must be refused and the connection closed. Such a request
|
||||
can therefore only be rejected, and only with the connection armed for close,
|
||||
because its payload bytes are still queued on the socket.
|
||||
|
||||
``identity`` used to be exempt as a coding that "frames nothing". It is not:
|
||||
it names no framing either, and RFC 7230 removed it from the transfer codings
|
||||
altogether. A sidecar rejection then answered 403 with keep-alive and the
|
||||
payload was parsed as the next request line
|
||||
(``400 Bad request syntax ('{...}GET /api/health/agent HTTP/1.1')``), and all
|
||||
four upload handlers fell through to a length-0 read, answered 400 "No file
|
||||
field in request" and left the multipart bytes on the socket
|
||||
(``400 Bad request syntax ('--x')``). Nothing legitimate is lost: no client
|
||||
sends a transfer coding this server could have honoured.
|
||||
|
||||
A header present but EMPTY (``Transfer-Encoding:``) is reported through
|
||||
``_BLANK_TRANSFER_CODING`` rather than as ``''``, so callers testing
|
||||
``is not None`` and the 411 message both read right. Only an ABSENT header
|
||||
yields no values at all -- absence is something only the wire can say.
|
||||
|
||||
The first value is enough precisely because no value is acceptable; it is
|
||||
reported as-is (SP/HTAB trimmed, the one padding RFC 9110 allows) purely so
|
||||
the rejection message names what arrived.
|
||||
"""
|
||||
values = _framing_header_values(handler, 'Transfer-Encoding')
|
||||
if not values:
|
||||
return None
|
||||
return values[0].strip(_FIELD_VALUE_OWS) or _BLANK_TRANSFER_CODING
|
||||
|
||||
|
||||
def arm_connection_close_if_body_pending(handler) -> bool:
|
||||
"""Arm close for a reject-before-read, but only if a body was really declared.
|
||||
|
||||
Returns whether the connection was armed. Use this instead of
|
||||
``arm_connection_close()`` wherever the rejection can also fire on a request
|
||||
with no body: closing then would drop a pooled client's healthy connection
|
||||
for no framing reason.
|
||||
"""
|
||||
if not request_declares_body(handler):
|
||||
return False
|
||||
arm_connection_close(handler)
|
||||
return True
|
||||
|
||||
|
||||
def advertise_connection_close(handler) -> None:
|
||||
"""Serialize ``close_connection`` into exactly one ``Connection: close`` header.
|
||||
|
||||
``BaseHTTPRequestHandler`` tracks ``close_connection`` internally but never
|
||||
writes it to the wire, so a server-side close is invisible to the client: a
|
||||
pooled client reuses the socket and fails with BrokenPipeError instead of
|
||||
opening a fresh connection. Call this from ``end_headers()``, before the
|
||||
header buffer is flushed.
|
||||
|
||||
Dedup is derived from the pending header buffer rather than a private flag,
|
||||
because the SSE endpoints send their own ``Connection: close`` (and
|
||||
``send_header`` itself flips ``close_connection`` when they do) — keying off
|
||||
a flag we set ourselves would emit the header twice on every stream.
|
||||
|
||||
Both attribute reads are defensive: partially-built handler stubs in the
|
||||
test suite carry neither ``close_connection`` nor ``_headers_buffer``, and
|
||||
header emission must not depend on their presence. The buffer is also
|
||||
type-checked before it is scanned -- a stub whose ``_headers_buffer`` is a
|
||||
truthy non-iterable (a bare ``Mock``) would otherwise raise ``TypeError``
|
||||
from inside ``end_headers()``, which ``_handle_write`` would swallow into a
|
||||
spurious 500.
|
||||
"""
|
||||
if not getattr(handler, 'close_connection', False):
|
||||
return
|
||||
buffered = getattr(handler, '_headers_buffer', None)
|
||||
for raw in buffered if isinstance(buffered, (list, tuple)) else ():
|
||||
if raw[:11].lower() == b'connection:':
|
||||
return
|
||||
handler.send_header('Connection', 'close')
|
||||
|
||||
|
||||
def flush_pending_auth_cookies(handler) -> None:
|
||||
pending = getattr(handler, '_pending_set_cookies', None)
|
||||
if not pending:
|
||||
@@ -472,6 +717,22 @@ _redact_fn_lru = functools.lru_cache(maxsize=4096)(_redact_fn_uncached)
|
||||
# thousands of small recurring strings that actually benefit, or balloon RSS.
|
||||
_REDACT_CACHE_MAX_TEXT_LEN = 16384
|
||||
|
||||
# Strings above that threshold deliberately stay UNCACHED and re-run the full
|
||||
# redactor on every request, even though they dominate the recurring cost of a
|
||||
# large session (measured: 59 large strings, 29 unique, 1.68s per request on a
|
||||
# real 22MB session). Memoizing them by input text alone is not safe:
|
||||
# ``agent.redact.register_redaction_patterns()`` lets a plugin extend the
|
||||
# secret matcher at runtime, and the installed registry exposes neither a
|
||||
# policy generation the cache key could include nor a hook that could clear
|
||||
# it. A large blob primed before such a registration would keep being served
|
||||
# with the newly-registered secret intact through session, SSE and
|
||||
# public-share projections. Until the agent registry exposes a generation,
|
||||
# large strings fail closed.
|
||||
#
|
||||
# tests/test_redact_large_string_cache.py pins this against the real agent
|
||||
# registry: prime a large string, register a pattern, the next response must
|
||||
# be redacted.
|
||||
|
||||
|
||||
def _redact_fn_cached(text):
|
||||
if len(text) > _REDACT_CACHE_MAX_TEXT_LEN:
|
||||
@@ -559,7 +820,37 @@ _SENSITIVE_DISCORD_MARKER_RE = _re.compile(r"<@!?\d{17,20}>")
|
||||
_SENSITIVE_PHONE_MARKER_RE = _re.compile(r"(?<![A-Za-z0-9])\+[1-9]\d{6,14}(?![A-Za-z0-9])")
|
||||
|
||||
|
||||
def _might_contain_sensitive_text(text: str) -> bool:
|
||||
# The prefilter runs on every string of every API response — ~74k strings /
|
||||
# ~19MB on a real large session — and cProfile showed it costing 2.98s of the
|
||||
# 3.7s redaction pass once the redactor's own caches are warm. The work is
|
||||
# pure and deterministic (fixed marker tuples, fixed patterns), so identical
|
||||
# text always yields the same verdict and is safe to memoize without
|
||||
# invalidation, exactly like `_redact_fn_cached` above.
|
||||
#
|
||||
# Note on what was NOT done: replacing the 71 `in` scans with one compiled
|
||||
# alternation looks like the obvious fix, but measured 38% SLOWER on the real
|
||||
# payload (2.60s vs 1.88s). CPython's substring search is already a tuned
|
||||
# C-level algorithm, and a large regex alternation has to try each branch at
|
||||
# each position. Memoizing the verdict avoids the scan entirely instead.
|
||||
#
|
||||
# Bounds: the cache retains the raw text as its key, so what matters for RSS
|
||||
# is the object's byte size, not its character count -- a 16k-character
|
||||
# string of 4-byte code points is ~64 KiB, and 8192 of them would pin ~512 MiB
|
||||
# for the process lifetime. Each entry is therefore gated on
|
||||
# ``sys.getsizeof(text)`` (O(1), counts the actual allocation, width-aware),
|
||||
# and the entry count is capped, which gives a hard ceiling on retained key
|
||||
# bytes of _SENSITIVE_PREFILTER_CACHE_SIZE * _SENSITIVE_PREFILTER_MAX_ENTRY_BYTES
|
||||
# (16 MiB) plus lru_cache's fixed per-entry link overhead. Values are the two
|
||||
# bool singletons and cost nothing. Strings above the byte gate -- clean or
|
||||
# sensitive -- simply run the scan uncached and are never retained.
|
||||
_SENSITIVE_PREFILTER_CACHE_SIZE = 8192
|
||||
_SENSITIVE_PREFILTER_MAX_ENTRY_BYTES = 2048
|
||||
_SENSITIVE_PREFILTER_MAX_RETAINED_KEY_BYTES = (
|
||||
_SENSITIVE_PREFILTER_CACHE_SIZE * _SENSITIVE_PREFILTER_MAX_ENTRY_BYTES
|
||||
)
|
||||
|
||||
|
||||
def _might_contain_sensitive_text_uncached(text: str) -> bool:
|
||||
"""Cheap prefilter before the full agent+fallback redaction pass."""
|
||||
if not isinstance(text, str) or not text:
|
||||
return False
|
||||
@@ -577,6 +868,25 @@ def _might_contain_sensitive_text(text: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
_might_contain_sensitive_text_lru = functools.lru_cache(
|
||||
maxsize=_SENSITIVE_PREFILTER_CACHE_SIZE
|
||||
)(_might_contain_sensitive_text_uncached)
|
||||
|
||||
|
||||
def _might_contain_sensitive_text(text: str) -> bool:
|
||||
"""Memoized wrapper around the prefilter.
|
||||
|
||||
Falls back to the uncached scan for non-strings and for any string whose
|
||||
object size exceeds the per-entry byte gate, so the cache can never be
|
||||
poisoned by an unhashable value and its retained bytes stay hard-bounded.
|
||||
"""
|
||||
if not isinstance(text, str) or not text:
|
||||
return False
|
||||
if sys.getsizeof(text) > _SENSITIVE_PREFILTER_MAX_ENTRY_BYTES:
|
||||
return _might_contain_sensitive_text_uncached(text)
|
||||
return _might_contain_sensitive_text_lru(text)
|
||||
|
||||
|
||||
def _redact_text(text: str, *, _enabled: bool | None = None) -> str:
|
||||
"""Redact sensitive text from API responses. Respects api_redact_enabled setting.
|
||||
|
||||
@@ -724,12 +1034,59 @@ _JPEG_SOF_MARKERS = {
|
||||
}
|
||||
|
||||
|
||||
def _is_complete_jpeg(raw: bytes) -> bool:
|
||||
def _mpf_image_ranges(data: bytes, base: int) -> list[tuple[int, int]] | None:
|
||||
"""Read an MP Index TIFF inside APP2; offsets are relative to its header.
|
||||
|
||||
Only the declared JPEG extents are trusted, not a decoder's willingness to
|
||||
ignore trailing bytes. The caller checks contiguous coverage and each JPEG.
|
||||
"""
|
||||
if len(data) < 8 or data[:4] not in {b"II\x2a\x00", b"MM\x00\x2a"}:
|
||||
return None
|
||||
order = "little" if data[:2] == b"II" else "big"
|
||||
|
||||
def uint(pos: int, size: int = 4) -> int:
|
||||
return int.from_bytes(data[pos:pos + size], order)
|
||||
|
||||
ifd = uint(4)
|
||||
if ifd < 8 or ifd + 2 > len(data):
|
||||
return None
|
||||
count = uint(ifd, 2)
|
||||
table_end = ifd + 2 + 12 * count
|
||||
if table_end + 4 > len(data):
|
||||
return None
|
||||
tags = {}
|
||||
for pos in range(ifd + 2, table_end, 12):
|
||||
tag = uint(pos, 2)
|
||||
if tag in tags:
|
||||
return None
|
||||
tags[tag] = (uint(pos + 2, 2), uint(pos + 4), uint(pos + 8))
|
||||
if tags.get(0xB001, ())[:2] != (4, 1):
|
||||
return None
|
||||
images = tags[0xB001][2]
|
||||
entry = tags.get(0xB002)
|
||||
if not entry or images < 2 or entry[:2] != (7, 16 * images):
|
||||
return None
|
||||
offset = entry[2]
|
||||
if offset < table_end + 4 or offset + 16 * images > len(data):
|
||||
return None
|
||||
ranges = []
|
||||
for index in range(images):
|
||||
pos = offset + index * 16
|
||||
attributes, size, relative = uint(pos), uint(pos + 4), uint(pos + 8)
|
||||
if attributes & 0x07000000 or size < 4 or (index == 0 and relative != 0):
|
||||
return None
|
||||
start = 0 if index == 0 else base + relative
|
||||
ranges.append((start, start + size))
|
||||
return ranges
|
||||
|
||||
|
||||
def _is_complete_jpeg(raw: bytes, *, _allow_mpf: bool = True) -> bool:
|
||||
if len(raw) < 4 or raw[:2] != b"\xff\xd8":
|
||||
return False
|
||||
pos = 2
|
||||
saw_sof = False
|
||||
saw_scan = False
|
||||
mpf_ranges = None
|
||||
while pos < len(raw):
|
||||
marker_start = pos
|
||||
if raw[pos] != 0xFF:
|
||||
@@ -741,7 +1098,20 @@ def _is_complete_jpeg(raw: bytes) -> bool:
|
||||
marker = raw[pos]
|
||||
pos += 1
|
||||
if marker == 0xD9:
|
||||
return saw_sof and saw_scan and pos == len(raw)
|
||||
if not saw_sof or not saw_scan:
|
||||
return False
|
||||
if mpf_ranges is None:
|
||||
return pos == len(raw)
|
||||
if mpf_ranges[0] != (0, pos):
|
||||
return False
|
||||
# No gaps, overlaps, undeclared pictures or post-EOI payloads.
|
||||
for start, end in mpf_ranges[1:]:
|
||||
if start != pos or end > len(raw):
|
||||
return False
|
||||
if not _is_complete_jpeg(raw[start:end], _allow_mpf=False):
|
||||
return False
|
||||
pos = end
|
||||
return pos == len(raw)
|
||||
if marker in {0x00, 0x01, 0xD8} or 0xD0 <= marker <= 0xD7:
|
||||
return False
|
||||
if pos + 2 > len(raw):
|
||||
@@ -752,6 +1122,12 @@ def _is_complete_jpeg(raw: bytes) -> bool:
|
||||
segment_end = pos + segment_length
|
||||
if segment_end > len(raw):
|
||||
return False
|
||||
if _allow_mpf and marker == 0xE2 and raw[pos + 2:pos + 6] == b"MPF\x00":
|
||||
if mpf_ranges is not None:
|
||||
return False
|
||||
mpf_ranges = _mpf_image_ranges(raw[pos + 6:segment_end], pos + 6)
|
||||
if mpf_ranges is None:
|
||||
return False
|
||||
if marker in _JPEG_SOF_MARKERS:
|
||||
if segment_length < 8:
|
||||
return False
|
||||
@@ -956,13 +1332,45 @@ def _redact_value(v, *, _enabled: bool | None = None):
|
||||
|
||||
``_enabled`` is threaded through so a single response-level redact pass
|
||||
only reads settings.json once. (Opus pre-release perf fix.)
|
||||
|
||||
Containers are rebuilt only when a descendant actually changed. The
|
||||
overwhelming majority of a transcript carries no credential marker, so the
|
||||
previous unconditional dict/list comprehension deep-copied the entire
|
||||
payload — tens of MB per response — to reproduce an identical structure.
|
||||
That copy is pure CPU under the GIL (allocation and refcounting never
|
||||
release it), which serialized concurrent tab loads on a threaded server.
|
||||
|
||||
Returning the original object when nothing changed is safe because callers
|
||||
treat redacted output as read-only: ``_public_message_projection`` builds a
|
||||
fresh ``item`` dict per message, and ``redact_session_data`` builds a fresh
|
||||
``result``. Nothing mutates a value returned from here in place, so sharing
|
||||
an unmodified subtree cannot leak a later mutation back into session state.
|
||||
The redacting path is unchanged: as soon as one string is masked, every
|
||||
container on the path to it is rebuilt and the caller's original is left
|
||||
untouched.
|
||||
"""
|
||||
if isinstance(v, str):
|
||||
return _redact_text(v, _enabled=_enabled)
|
||||
if isinstance(v, dict):
|
||||
return {key: _redact_value(value, _enabled=_enabled) for key, value in v.items()}
|
||||
out = None
|
||||
for key, value in v.items():
|
||||
redacted = _redact_value(value, _enabled=_enabled)
|
||||
if redacted is value:
|
||||
continue
|
||||
if out is None:
|
||||
out = dict(v)
|
||||
out[key] = redacted
|
||||
return v if out is None else out
|
||||
if isinstance(v, list):
|
||||
return [_redact_value(item, _enabled=_enabled) for item in v]
|
||||
out = None
|
||||
for index, item in enumerate(v):
|
||||
redacted = _redact_value(item, _enabled=_enabled)
|
||||
if redacted is item:
|
||||
continue
|
||||
if out is None:
|
||||
out = list(v)
|
||||
out[index] = redacted
|
||||
return v if out is None else out
|
||||
return v
|
||||
|
||||
|
||||
|
||||
+13
-6
@@ -19,6 +19,7 @@ import time
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from urllib.parse import parse_qs, unquote
|
||||
|
||||
from api.agent_compat import agent_attr
|
||||
from api.helpers import bad, j
|
||||
from api.workspace import resolve_trusted_workspace
|
||||
|
||||
@@ -33,6 +34,11 @@ def _kb():
|
||||
return kb
|
||||
|
||||
|
||||
def _kb_connect(kb, board=None):
|
||||
"""Raw ``kb.connect`` (moved to ``hermes_cli.kanban_db_connect`` by the Agent split)."""
|
||||
return agent_attr(kb, "connect", "hermes_cli.kanban_db_connect")(board=board)
|
||||
|
||||
|
||||
def _resolve_board(parsed):
|
||||
"""Validate and normalise a ?board=<slug> query param.
|
||||
|
||||
@@ -93,13 +99,13 @@ def _conn(board=None):
|
||||
"""
|
||||
kb = _kb()
|
||||
kb.init_db(board=board)
|
||||
closing = getattr(kb, "connect_closing", None)
|
||||
closing = agent_attr(kb, "connect_closing", "hermes_cli.kanban_db_connect", None)
|
||||
if closing is not None:
|
||||
return closing(board=board)
|
||||
# Older kanban_db builds (and lightweight test doubles) without
|
||||
# connect_closing: fall back to the raw connection; sqlite3's own
|
||||
# context manager at least scopes the transaction.
|
||||
return kb.connect(board=board)
|
||||
return _kb_connect(kb, board=board)
|
||||
|
||||
|
||||
def _obj_dict(value):
|
||||
@@ -720,10 +726,11 @@ def _dispatch_payload(parsed):
|
||||
kb = _kb()
|
||||
dry_run = _bool_query(parsed, "dry_run", False)
|
||||
max_spawn = _int_query(parsed, "max", 8, minimum=1, maximum=100)
|
||||
if not hasattr(kb, "dispatch_once"):
|
||||
dispatch_once = agent_attr(kb, "dispatch_once", "hermes_cli.kanban_db_dispatch", None)
|
||||
if dispatch_once is None:
|
||||
raise ValueError("dispatcher is unavailable")
|
||||
with _conn(board=board) as conn:
|
||||
result = kb.dispatch_once(conn, dry_run=dry_run, max_spawn=max_spawn)
|
||||
result = dispatch_once(conn, dry_run=dry_run, max_spawn=max_spawn)
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
try:
|
||||
@@ -787,7 +794,7 @@ def _board_counts_for_slug(slug):
|
||||
if not kb.board_exists(slug):
|
||||
return {}
|
||||
try:
|
||||
conn = kb.connect(board=slug)
|
||||
conn = _kb_connect(kb, board=slug)
|
||||
except Exception:
|
||||
return {}
|
||||
try:
|
||||
@@ -1021,7 +1028,7 @@ def _kanban_sse_fetch_new(board, cursor):
|
||||
if board != default_slug and not kb.board_exists(board):
|
||||
return cursor, []
|
||||
try:
|
||||
conn = kb.connect(board=board)
|
||||
conn = _kb_connect(kb, board=board)
|
||||
except Exception:
|
||||
return cursor, []
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Profile-bound view of Hermes Agent's in-process MCP runtime.
|
||||
|
||||
The MCP status page, the tool inventory and ``/reload-mcp`` read or reset
|
||||
Hermes Agent's MCP ledger from the HTTP request thread. Hermes Agent scopes
|
||||
that ledger per profile (connection key ``(profile_home_key, name)``) only
|
||||
when the calling task serves a *routed* profile, i.e. its context-local
|
||||
Hermes-home override differs from the process home. These helpers bind the
|
||||
request profile's home (without touching ``os.environ``) and check that the
|
||||
agent's routing decision matches the profile WebUI resolved, so a read never
|
||||
shows another profile's connection and a reload never resets one.
|
||||
|
||||
Ledger helpers below take ``core`` (``tools.mcp_tool``) explicitly and read
|
||||
its maps with defaults, so an agent that lacks a map degrades to "unscoped"
|
||||
rather than raising inside a status read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Generator, Iterable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Scope label reported by the MCP endpoints.
|
||||
SCOPE_PROFILE = "profile" # runtime view bound to the request profile
|
||||
SCOPE_LEGACY = "legacy_process" # agent predates profile-scoped MCP; process-wide view
|
||||
SCOPE_UNAVAILABLE = "unavailable" # profile scope could not be confirmed; runtime hidden
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class McpRuntimeView:
|
||||
"""How the current context sees Hermes Agent's MCP runtime.
|
||||
|
||||
``trusted``: runtime reads/writes in this context belong to ``profile_home``.
|
||||
``legacy``: the agent has no profile-scoped MCP ledger (process-wide by name).
|
||||
``registry_scope``: the tool-registry slot owning this profile's MCP tools
|
||||
(the profile overlay key when routed, ``None`` for the process profile).
|
||||
"""
|
||||
|
||||
profile_home: Path
|
||||
trusted: bool
|
||||
legacy: bool
|
||||
registry_scope: Optional[str] = None
|
||||
|
||||
@property
|
||||
def scope_label(self) -> str:
|
||||
if not self.trusted:
|
||||
return SCOPE_UNAVAILABLE
|
||||
return SCOPE_LEGACY if self.legacy else SCOPE_PROFILE
|
||||
|
||||
|
||||
def _routing_view(profile_home: Path, override_bound: bool) -> McpRuntimeView:
|
||||
try:
|
||||
from agent.secret_scope import serves_routed_profile
|
||||
from hermes_constants import hermes_home_key
|
||||
from tools.registry import registry
|
||||
except ImportError:
|
||||
return McpRuntimeView(profile_home, trusted=True, legacy=True)
|
||||
from api.profiles import get_process_profile_home
|
||||
|
||||
try:
|
||||
expected_routed = hermes_home_key(profile_home) != hermes_home_key(get_process_profile_home())
|
||||
# Without the override a routed profile cannot be served; with it, the agent must
|
||||
# agree. It disagrees when a streaming turn has mirrored this profile's home into
|
||||
# os.environ['HERMES_HOME'] on an agent without a pinned process home.
|
||||
if expected_routed and not override_bound:
|
||||
return McpRuntimeView(profile_home, trusted=False, legacy=False)
|
||||
if bool(serves_routed_profile()) != expected_routed:
|
||||
return McpRuntimeView(profile_home, trusted=False, legacy=False)
|
||||
scope = registry.current_scope_key() if expected_routed else None
|
||||
except Exception:
|
||||
logger.debug("Failed to resolve MCP runtime scope for %s", profile_home, exc_info=True)
|
||||
return McpRuntimeView(profile_home, trusted=False, legacy=False)
|
||||
return McpRuntimeView(profile_home, trusted=True, legacy=False, registry_scope=scope)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def mcp_runtime_scope(purpose: str) -> Generator[McpRuntimeView, None, None]:
|
||||
"""Bind the active request profile for Hermes Agent MCP runtime calls.
|
||||
|
||||
Installs the context-local Hermes-home override and secret scope for the
|
||||
request profile (including the root profile) and restores them on exit,
|
||||
including on exceptions. Never mutates ``os.environ``, starts, or probes
|
||||
MCP servers.
|
||||
"""
|
||||
from api.profiles import get_active_hermes_home, profile_env_for_active_request_readonly
|
||||
|
||||
profile_home = Path(get_active_hermes_home())
|
||||
with profile_env_for_active_request_readonly(
|
||||
purpose, logger_override=logger, include_root=True
|
||||
) as override_bound:
|
||||
yield _routing_view(profile_home, bool(override_bound))
|
||||
|
||||
|
||||
def accepts_keywords(fn: Callable, *names: str) -> bool:
|
||||
"""True when *fn*'s signature exposes every keyword in *names*.
|
||||
|
||||
Introspection failures (builtins, C callables, exotic wrappers) count as
|
||||
"not supported" so callers fall back to the bare call instead of dropping
|
||||
the whole read.
|
||||
"""
|
||||
try:
|
||||
params = inspect.signature(fn).parameters
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return all(name in params for name in names)
|
||||
|
||||
|
||||
# ── Ledger keys ──────────────────────────────────────────────────────────────
|
||||
# Hermes Agent's own helpers (``tools.mcp_tool_scope``) are preferred so the key
|
||||
# shape has one owner; the lambdas only cover agents that predate that module.
|
||||
|
||||
def _key_helper(name: str, fallback: Callable) -> Callable:
|
||||
try:
|
||||
# Full-name import: works with a stubbed module and no ``tools`` package.
|
||||
mcp_tool_scope = importlib.import_module("tools.mcp_tool_scope")
|
||||
except Exception:
|
||||
return fallback
|
||||
helper = getattr(mcp_tool_scope, name, None)
|
||||
return helper if callable(helper) else fallback
|
||||
|
||||
|
||||
def mcp_key_name(key: Any) -> str:
|
||||
"""Server name of an MCP connection-ledger key (bare name or ``(scope, name)``)."""
|
||||
return str(_key_helper("_key_name", lambda k: k[1] if isinstance(k, tuple) else k)(key))
|
||||
|
||||
|
||||
def mcp_key_scope(key: Any) -> Optional[str]:
|
||||
"""Owning registry scope encoded in a ledger key (``None`` for a bare key)."""
|
||||
return _key_helper("_key_scope", lambda k: k[0] if isinstance(k, tuple) else None)(key)
|
||||
|
||||
|
||||
def ledger_key_owner(core: Any, key: Any) -> Optional[str]:
|
||||
"""Scope owning the connection under *key*: recorded at adoption, else the key's own."""
|
||||
owners = getattr(core, "_server_scope_keys", None)
|
||||
if isinstance(owners, dict) and key in owners:
|
||||
return owners[key]
|
||||
return mcp_key_scope(key)
|
||||
|
||||
|
||||
def ledger_key_serves_view(core: Any, key: Any, view: McpRuntimeView) -> bool:
|
||||
"""True when the connection under *key* serves the profile in *view*.
|
||||
|
||||
Owned by the view's registry scope, or adopted into it (a routed profile
|
||||
sharing an identical connection; ``_server_tool_scopes``). Hermes Agent's
|
||||
own launch-profile view (scope ``None``) is process-wide, so this predicate
|
||||
is what narrows the root profile to its unscoped connections. Legacy agents
|
||||
have one process-wide ledger, so every key counts.
|
||||
"""
|
||||
if view.legacy:
|
||||
return True
|
||||
if ledger_key_owner(core, key) == view.registry_scope:
|
||||
return True
|
||||
if view.registry_scope is None:
|
||||
return False
|
||||
adopters = getattr(core, "_server_tool_scopes", None)
|
||||
if not isinstance(adopters, dict):
|
||||
return False
|
||||
return view.registry_scope in (adopters.get(key) or ())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServedRuntime:
|
||||
"""Runtime state of one server name as seen from a profile's ledger keys."""
|
||||
|
||||
live_tools: Optional[int] = None # tool count of a live (session-bearing) connection
|
||||
connecting: bool = False
|
||||
error: Optional[str] = None
|
||||
lazy_tools: Optional[int] = None
|
||||
|
||||
@property
|
||||
def live(self) -> bool:
|
||||
return self.live_tools is not None
|
||||
|
||||
|
||||
def _served_keys(core: Any, ledger: str, view: McpRuntimeView) -> list:
|
||||
keys = getattr(core, ledger, None)
|
||||
if not isinstance(keys, (dict, set, frozenset, list, tuple)):
|
||||
return []
|
||||
return [key for key in list(keys) if ledger_key_serves_view(core, key, view)]
|
||||
|
||||
|
||||
def view_served_runtime(core: Any, view: McpRuntimeView) -> dict[str, ServedRuntime]:
|
||||
"""Per-name runtime state of the ledger entries serving *view*.
|
||||
|
||||
Caller holds ``core._lock`` or tolerates a racy read (status surfaces).
|
||||
"""
|
||||
live: dict[str, int] = {}
|
||||
connecting: set[str] = set()
|
||||
errors: dict[str, str] = {}
|
||||
lazy: dict[str, int] = {}
|
||||
servers = getattr(core, "_servers", None)
|
||||
if isinstance(servers, dict):
|
||||
for key in _served_keys(core, "_servers", view):
|
||||
server = servers.get(key)
|
||||
if server is None or getattr(server, "session", None) is None:
|
||||
continue # parked / mid-connect task: retained, not connected
|
||||
names = getattr(server, "_registered_tool_names", None)
|
||||
if not isinstance(names, (list, tuple, set)):
|
||||
names = getattr(server, "_tools", None) or ()
|
||||
live[mcp_key_name(key)] = len(names)
|
||||
for key in _served_keys(core, "_server_connecting", view):
|
||||
connecting.add(mcp_key_name(key))
|
||||
error_map = getattr(core, "_server_connect_errors", None)
|
||||
if isinstance(error_map, dict):
|
||||
for key in _served_keys(core, "_server_connect_errors", view):
|
||||
errors[mcp_key_name(key)] = str(error_map.get(key, ""))
|
||||
lazy_tools = getattr(core, "_lazy_server_tool_names", None)
|
||||
for key in _served_keys(core, "_lazy_server_configs", view):
|
||||
count = lazy_tools.get(key) if isinstance(lazy_tools, dict) else None
|
||||
lazy[mcp_key_name(key)] = len(count) if isinstance(count, (list, tuple, set)) else 0
|
||||
out: dict[str, ServedRuntime] = {}
|
||||
for name in set(live) | connecting | set(errors) | set(lazy):
|
||||
out[name] = ServedRuntime(
|
||||
live_tools=live.get(name),
|
||||
connecting=name in connecting,
|
||||
error=errors.get(name),
|
||||
lazy_tools=lazy.get(name),
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
_RUNTIME_ROW_FIELDS = ("connected", "tools", "status", "error", "sampling", "tool_schemas")
|
||||
|
||||
|
||||
def filter_runtime_status_to_view(statuses: Iterable[Any], view: McpRuntimeView) -> list:
|
||||
"""Rebuild the runtime fields of ``get_mcp_status()`` rows from the keys serving *view*.
|
||||
|
||||
Under a routed scope the agent already filters by owner/adopter; under the
|
||||
launch profile (scope ``None``) it reports every profile's connection and
|
||||
indexes them by bare name, so the root profile would show "Active, N tools"
|
||||
for a routed profile's server it never connected (or its own parked server
|
||||
as connected because another profile's same-named one is live). Rows keep
|
||||
their config-derived fields (name, transport, disabled) and get
|
||||
``connected`` / ``tools`` / ``status`` / ``error`` from this profile's own
|
||||
ledger entries only.
|
||||
"""
|
||||
rows = [row for row in statuses if isinstance(row, dict)]
|
||||
if view.legacy or not rows:
|
||||
return rows
|
||||
try:
|
||||
core = importlib.import_module("tools.mcp_tool")
|
||||
except Exception:
|
||||
return rows
|
||||
lock = getattr(core, "_lock", None)
|
||||
try:
|
||||
if lock is not None:
|
||||
with lock:
|
||||
served = view_served_runtime(core, view)
|
||||
else:
|
||||
served = view_served_runtime(core, view)
|
||||
except Exception:
|
||||
logger.debug("Failed to read MCP ledger ownership for %s", view.profile_home, exc_info=True)
|
||||
return []
|
||||
out = []
|
||||
for row in rows:
|
||||
rebuilt = {k: v for k, v in row.items() if k not in _RUNTIME_ROW_FIELDS}
|
||||
state = served.get(str(row.get("name")))
|
||||
disabled = bool(row.get("disabled")) or row.get("status") == "disabled"
|
||||
rebuilt["connected"] = bool(state and state.live)
|
||||
rebuilt["tools"] = state.live_tools if state and state.live else (
|
||||
state.lazy_tools if state and state.lazy_tools is not None and not disabled else 0
|
||||
)
|
||||
if state and state.live:
|
||||
rebuilt["status"] = "connected"
|
||||
elif disabled:
|
||||
rebuilt["status"] = "disabled"
|
||||
elif state and state.connecting:
|
||||
rebuilt["status"] = "connecting"
|
||||
elif state and state.error is not None:
|
||||
rebuilt["status"] = "failed"
|
||||
rebuilt["error"] = state.error
|
||||
elif state and state.lazy_tools is not None:
|
||||
rebuilt["status"] = "lazy"
|
||||
else:
|
||||
rebuilt["status"] = "configured"
|
||||
out.append(rebuilt)
|
||||
return out
|
||||
|
||||
|
||||
_COOLDOWN_LEDGERS = ("_server_connect_retry_after", "_server_connect_failures", "_server_connect_errors")
|
||||
|
||||
|
||||
def clear_profile_connect_cooldowns(core: Any, view: McpRuntimeView) -> None:
|
||||
"""Forget connect backoff/error state of the servers *view*'s profile owns.
|
||||
|
||||
A server that failed to spawn never reaches ``_servers``, so a scoped
|
||||
``shutdown_mcp_servers(scope=..., names=...)`` (which clears cooldowns for
|
||||
the live keys it tears down) leaves its backoff in place and the following
|
||||
``discover_mcp_tools()`` skips it. The process-wide wildcard cleared every
|
||||
cooldown; this keeps ``/reload-mcp`` a retry for the profile's own servers
|
||||
without touching another owner's backoff. Owned keys only, never adopted ones.
|
||||
"""
|
||||
lock = getattr(core, "_lock", None)
|
||||
if lock is None:
|
||||
return
|
||||
with lock:
|
||||
for ledger in _COOLDOWN_LEDGERS:
|
||||
entries = getattr(core, ledger, None)
|
||||
if not isinstance(entries, dict):
|
||||
continue
|
||||
for key in [k for k in entries if ledger_key_owner(core, k) == view.registry_scope]:
|
||||
entries.pop(key, None)
|
||||
|
||||
|
||||
def registry_tool_owned_by_view(registry, tool_name: str, view: McpRuntimeView) -> bool:
|
||||
"""True when *tool_name* is registered in this profile's own registry slot.
|
||||
|
||||
The merged registry view overlays a profile's tools on the global slot, so a
|
||||
routed profile would otherwise also list the process profile's MCP tools.
|
||||
"""
|
||||
if view.legacy:
|
||||
return True
|
||||
snapshot = getattr(registry, "snapshot_registration", None)
|
||||
if not callable(snapshot):
|
||||
return True
|
||||
try:
|
||||
return snapshot(tool_name, scope=view.registry_scope) is not None
|
||||
except Exception:
|
||||
return False
|
||||
+88
-2
@@ -228,6 +228,66 @@ def snapshot_path_for_digest(digest: str) -> Path | None:
|
||||
return candidate if candidate.is_file() else None
|
||||
|
||||
|
||||
def codex_commentary_text(message) -> str:
|
||||
"""#7565: typed public assistant commentary carried in
|
||||
``codex_message_items`` is eligible for the same exact
|
||||
session-scoped MEDIA: grant as public top-level assistant content.
|
||||
|
||||
The Agent producer (``agent/codex_responses_adapter.py``) stamps
|
||||
message items with ``role: assistant`` and a normalized phase.
|
||||
Only the public ``commentary`` phase, only the textual
|
||||
``output_text`` part, and only when the outer row role is
|
||||
``assistant`` is honored. Reasoning, analysis, final phases,
|
||||
user-owned sidecars, and arbitrary nested fields are not
|
||||
inspected — keeping the extraction in one neutral helper so
|
||||
the authorization predicate (``api/routes.py::
|
||||
_session_media_token_allows_path``) and the snapshot scan
|
||||
(``annotate_media_snapshots`` below) cannot drift apart.
|
||||
|
||||
Fail-closed predicate, in order: outer role is assistant;
|
||||
``codex_message_items`` is a list; item type is ``message``;
|
||||
item role is ``assistant``; item phase normalizes to
|
||||
``commentary``; content is a list; the part is textual
|
||||
``output_text`` with a string ``text`` value. Any step that
|
||||
fails returns an empty string, so this helper is safe to
|
||||
concatenate with the existing top-level text extraction.
|
||||
"""
|
||||
if not isinstance(message, dict):
|
||||
return ""
|
||||
# Outer role must be assistant. User rows and their sidecars
|
||||
# cannot mint grants even when the sidecar contains a
|
||||
# commentary-shaped item, preserving the implicit threat model
|
||||
# that user-authored content cannot mint allow-list entries.
|
||||
role = str(message.get("role") or "").strip().lower()
|
||||
if role != "assistant":
|
||||
return ""
|
||||
items = message.get("codex_message_items")
|
||||
if not isinstance(items, list):
|
||||
return ""
|
||||
parts = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if str(item.get("type") or "").strip().lower() != "message":
|
||||
continue
|
||||
if str(item.get("role") or "").strip().lower() != "assistant":
|
||||
continue
|
||||
if str(item.get("phase") or "").strip().lower() != "commentary":
|
||||
continue
|
||||
content = item.get("content")
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
if str(part.get("type") or "").strip().lower() != "output_text":
|
||||
continue
|
||||
text = part.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _binding_path_for_digest(digest: str) -> Path:
|
||||
"""Sidecar holding the source-path set for a digest (``<digest>.src.json``)."""
|
||||
return get_snapshot_dir() / f"{digest}.src.json"
|
||||
@@ -445,14 +505,40 @@ def annotate_media_snapshots(
|
||||
if allowed_predicate is None:
|
||||
allowed_predicate = media_capture_allowed
|
||||
media_re = _re.compile(r"MEDIA:([^\s\)\]]+)")
|
||||
# #7680 re-gate (9/22): two-pass scan. First strip backtick
|
||||
# wrappers (`` `MEDIA:path` `` → ``MEDIA:path``) so the bare
|
||||
# class below does not consume the closing backtick as part of
|
||||
# the path. Then the bare class (no backtick in the exclusion
|
||||
# set) captures the full filename even when the path itself
|
||||
# contains a backtick (e.g. ``report`final.png``).
|
||||
backtick_re = _re.compile(r"`MEDIA:([^`\s]+)`")
|
||||
captured = 0
|
||||
for msg in messages or []:
|
||||
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||
continue
|
||||
# #7565: also inspect typed public assistant commentary
|
||||
# carried in ``codex_message_items`` (Agent phase:
|
||||
# "commentary"). The concatenated text below is the union of
|
||||
# the existing top-level string extraction and the new
|
||||
# commentary-only helper, so otherwise-capture-eligible
|
||||
# commentary previews are snapshotted at settle time. The
|
||||
# commentary helper is fail-closed (outer role must be
|
||||
# assistant; item type/role/phase all constrained; only
|
||||
# textual output_text parts are read) and matches the auth
|
||||
# predicate's extraction so capture and serve cannot drift
|
||||
# apart.
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, str) or "MEDIA:" not in content:
|
||||
commentary = codex_commentary_text(msg)
|
||||
text_parts = []
|
||||
if isinstance(content, str) and "MEDIA:" in content:
|
||||
text_parts.append(content)
|
||||
if commentary and "MEDIA:" in commentary:
|
||||
text_parts.append(commentary)
|
||||
if not text_parts:
|
||||
continue
|
||||
refs = media_re.findall(content)
|
||||
text = "\n".join(text_parts)
|
||||
text = backtick_re.sub(lambda m: f"MEDIA:{m.group(1)}", text)
|
||||
refs = media_re.findall(text)
|
||||
if not refs:
|
||||
continue
|
||||
existing = msg.get("_media_snapshots")
|
||||
|
||||
+2257
-181
File diff suppressed because it is too large.
Load diff
+7
-3
@@ -314,9 +314,13 @@ def _read_claude_code_credentials() -> dict[str, Any] | None:
|
||||
and macOS Keychain. Returns the credential dict or None.
|
||||
"""
|
||||
try:
|
||||
from agent.anthropic_adapter import (
|
||||
is_claude_code_token_valid,
|
||||
read_claude_code_credentials,
|
||||
from api.agent_compat import agent_attr
|
||||
|
||||
is_claude_code_token_valid = agent_attr(
|
||||
"agent.anthropic_adapter", "is_claude_code_token_valid", "agent.anthropic_credentials"
|
||||
)
|
||||
read_claude_code_credentials = agent_attr(
|
||||
"agent.anthropic_adapter", "read_claude_code_credentials", "agent.anthropic_credentials"
|
||||
)
|
||||
|
||||
creds = read_claude_code_credentials()
|
||||
|
||||
+3
-1
@@ -71,7 +71,9 @@ _SUPPORTED_PROVIDER_SETUPS = {
|
||||
"default_model": "anthropic/claude-sonnet-4.6",
|
||||
"requires_base_url": False,
|
||||
"models": [
|
||||
{"id": _to_openrouter_namespace(model["id"]), "label": model["label"]}
|
||||
{"id": "z-ai/glm-4.5-air", "label": "GLM-4.5 Air"}
|
||||
if model["id"] == "zai/glm-4.5-flash"
|
||||
else {"id": _to_openrouter_namespace(model["id"]), "label": model["label"]}
|
||||
for model in _FALLBACK_MODELS
|
||||
],
|
||||
"category": "easy_start",
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
import inspect
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
@@ -451,16 +452,46 @@ def complete_async_delegation_delivery(
|
||||
_mark_legacy_async_delivery_complete(claim.delegation_id)
|
||||
|
||||
|
||||
def _release_accepts_retryable(release_fn: Any) -> bool:
|
||||
"""Whether the core ``release_event_delivery`` supports ``retryable=``.
|
||||
|
||||
Older Agent cores predate the keyword; calling them with it would raise
|
||||
``TypeError`` and skip the release entirely.
|
||||
"""
|
||||
try:
|
||||
params = inspect.signature(release_fn).parameters
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if "retryable" in params:
|
||||
return params["retryable"].kind in (
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
)
|
||||
return any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
|
||||
|
||||
|
||||
def release_async_delegation_delivery(
|
||||
evt: Any,
|
||||
claim: AsyncDelegationDeliveryClaim,
|
||||
*,
|
||||
retryable: bool = False,
|
||||
) -> None:
|
||||
"""Release a failed claim so a later WebUI consumer can retry it."""
|
||||
"""Release a failed claim so a later WebUI consumer can retry it.
|
||||
|
||||
``retryable=True`` tells the core the refusal was transient, so the claimed
|
||||
attempt is refunded instead of counting against the bounded delivery
|
||||
budget (``tools.async_delegation.release_event_delivery(...,
|
||||
retryable=True)``). A core that predates the keyword gets a plain release:
|
||||
the attempt is then consumed as before, never an exception.
|
||||
"""
|
||||
try:
|
||||
if claim.durable:
|
||||
from tools.async_delegation import release_event_delivery
|
||||
|
||||
release_event_delivery(evt, claim.claim_id)
|
||||
if retryable and _release_accepts_retryable(release_event_delivery):
|
||||
release_event_delivery(evt, claim.claim_id, retryable=True)
|
||||
else:
|
||||
release_event_delivery(evt, claim.claim_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to release durable async delegation delivery for %s",
|
||||
|
||||
+117
-16
@@ -56,6 +56,11 @@ _loaded_profile_env_keys: set[str] = set()
|
||||
# process-global _active_profile.
|
||||
_tls = threading.local()
|
||||
|
||||
# Home of the profile this process serves as its own (set by init_profile_state).
|
||||
# Streaming turns mirror their profile into os.environ['HERMES_HOME'], so the live
|
||||
# env var is not a stable identity; see _pin_process_profile_home().
|
||||
_PROCESS_PROFILE_HOME: Optional[str] = None
|
||||
|
||||
_SKILL_HOME_MODULES = ("tools.skills_tool", "tools.skill_manager_tool")
|
||||
_SKILL_HOME_MODULE_PATCH_LOCK = threading.RLock()
|
||||
|
||||
@@ -198,7 +203,19 @@ def _unwrap_profile_home_to_base(home: Path) -> Path:
|
||||
# are operator/deployment-level postures, not per-profile toggles. Letting a
|
||||
# profile .env set HERMES_WEBUI_ISOLATED_PROFILE=0 would let a contained user
|
||||
# escape isolation (#4589).
|
||||
_PROTECTED_ENV_KEYS = frozenset({'HERMES_WEBUI_ISOLATED_PROFILE'})
|
||||
_PROTECTED_ENV_KEYS = frozenset({
|
||||
'HERMES_WEBUI_ISOLATED_PROFILE',
|
||||
# Server-wide sidebar window; a profile .env must not widen the per-request query.
|
||||
'HERMES_WEBUI_VISIBLE_SESSION_LIMIT',
|
||||
# #7656 round-3: HERMES_WEBUI_MAX_SESSION_RESOLVE is a process-wide resource
|
||||
# cap (controls the BoundedSemaphore that gates full-transcript resolves).
|
||||
# A profile's .env is per-profile by design; allowing it to set a
|
||||
# server-wide limit (and a first-load-wins asymmetry) would let a
|
||||
# single profile drag the rest into a tighter or looser posture than
|
||||
# the operator intended. Same shape as the isolated-profile key: only
|
||||
# the operator/launcher env at startup can set it.
|
||||
'HERMES_WEBUI_MAX_SESSION_RESOLVE',
|
||||
})
|
||||
|
||||
|
||||
def _isolated_profile_opt_in() -> bool:
|
||||
@@ -934,6 +951,13 @@ _BLOCKED_RUNTIME_ENV_KEYS = {
|
||||
# #4589: operator/deployment isolation posture — never overridable by a
|
||||
# profile's own env on any runtime/gateway-parity path.
|
||||
'HERMES_WEBUI_ISOLATED_PROFILE',
|
||||
'HERMES_WEBUI_VISIBLE_SESSION_LIMIT',
|
||||
# #7656 round-3: process-wide resolve cap. Filter out a profile's
|
||||
# .env override the same way we filter the isolated-profile key, so
|
||||
# the cap stays at the operator/launcher value across profile
|
||||
# switches rather than re-sizing to whatever the first-loaded
|
||||
# profile env set.
|
||||
'HERMES_WEBUI_MAX_SESSION_RESOLVE',
|
||||
}
|
||||
|
||||
|
||||
@@ -1341,6 +1365,8 @@ def profile_env_for_background_worker(
|
||||
def profile_env_for_active_request_readonly(
|
||||
purpose: str = "provider/model read",
|
||||
logger_override: Optional[logging.Logger] = None,
|
||||
*,
|
||||
include_root: bool = False,
|
||||
):
|
||||
"""Apply the active per-request profile's env to thread-local state only (#3957).
|
||||
|
||||
@@ -1357,11 +1383,18 @@ def profile_env_for_active_request_readonly(
|
||||
process-global ``os.environ``.
|
||||
|
||||
No-ops for the default/root profile, which is the common single-profile
|
||||
deployment case.
|
||||
deployment case, unless ``include_root`` is set: callers whose Hermes Agent
|
||||
reads must not follow a streaming turn's mirrored ``HERMES_HOME`` (MCP
|
||||
runtime status/reload) bind the root profile's home explicitly too.
|
||||
|
||||
Yields True when the context-local Hermes-home override is installed for
|
||||
the resolved profile, else False (no-op, older agent, or resolution error).
|
||||
"""
|
||||
profile = (get_active_profile_name() or "").strip()
|
||||
if not profile or _is_root_profile(profile):
|
||||
yield
|
||||
if include_root and not profile:
|
||||
profile = "default"
|
||||
if not profile or (_is_root_profile(profile) and not include_root):
|
||||
yield False
|
||||
return
|
||||
try:
|
||||
from api.config import _clear_thread_env, _set_thread_env, _thread_ctx
|
||||
@@ -1377,7 +1410,7 @@ def profile_env_for_active_request_readonly(
|
||||
purpose,
|
||||
exc_info=True,
|
||||
)
|
||||
yield
|
||||
yield False
|
||||
return
|
||||
try:
|
||||
from hermes_constants import (
|
||||
@@ -1421,7 +1454,7 @@ def profile_env_for_active_request_readonly(
|
||||
purpose,
|
||||
exc_info=True,
|
||||
)
|
||||
yield
|
||||
yield home_override_installed
|
||||
finally:
|
||||
if _has_scope and _secret_scope_mod is not None:
|
||||
try:
|
||||
@@ -1510,8 +1543,14 @@ def profile_scope_for_detached_worker(
|
||||
|
||||
|
||||
def _set_hermes_home(home: Path):
|
||||
"""Set HERMES_HOME env var and monkey-patch cached module-level paths."""
|
||||
"""Set HERMES_HOME env var and monkey-patch cached module-level paths.
|
||||
|
||||
Every process-wide home change (startup, ``switch_profile(process_wide=True)``)
|
||||
goes through here, so the process-profile pin used for MCP routing decisions
|
||||
is updated in the same step and cannot drift from ``HERMES_HOME``.
|
||||
"""
|
||||
os.environ['HERMES_HOME'] = str(home)
|
||||
_pin_process_profile_home(home)
|
||||
|
||||
patch_skill_home_modules(home)
|
||||
|
||||
@@ -1591,11 +1630,45 @@ def init_profile_state() -> None:
|
||||
else:
|
||||
_active_profile = _read_active_profile_file()
|
||||
home = get_active_hermes_home()
|
||||
_set_hermes_home(home)
|
||||
_set_hermes_home(home) # also pins the process-profile home (MCP routing anchor)
|
||||
install_cron_scheduler_profile_isolation()
|
||||
_reload_dotenv(home)
|
||||
|
||||
|
||||
def _pin_process_profile_home(home: Path) -> None:
|
||||
"""Record the profile home this process serves as its own, for WebUI and Hermes Agent.
|
||||
|
||||
Called from ``_set_hermes_home()`` so startup and process-wide profile switches
|
||||
keep one owner for the value. Hermes Agent keys MCP connections and registry
|
||||
overlays by profile only when a task serves a *routed* profile: the context-local
|
||||
Hermes-home override differs from the process home. Streaming turns mirror their
|
||||
profile into ``os.environ['HERMES_HOME']`` for legacy readers, which makes every
|
||||
turn's own profile look like the process profile, so same-named MCP servers of
|
||||
different profiles share one bare-name connection. Agents exposing
|
||||
``hermes_constants.pin_process_hermes_home`` take a stable anchor instead; older
|
||||
agents keep following the env var, and ``get_process_profile_home()`` still gives
|
||||
WebUI the anchor to detect that skew.
|
||||
"""
|
||||
global _PROCESS_PROFILE_HOME
|
||||
_PROCESS_PROFILE_HOME = str(home)
|
||||
try:
|
||||
import hermes_constants
|
||||
pin = getattr(hermes_constants, 'pin_process_hermes_home', None)
|
||||
if callable(pin):
|
||||
pin(str(home))
|
||||
except Exception:
|
||||
logger.debug("Hermes Agent process-home pin unavailable", exc_info=True)
|
||||
|
||||
|
||||
def get_process_profile_home() -> Path:
|
||||
"""Return the profile home this WebUI process serves as its own (stable per process)."""
|
||||
if _PROCESS_PROFILE_HOME:
|
||||
return Path(_PROCESS_PROFILE_HOME)
|
||||
if _INITIAL_HERMES_HOME:
|
||||
return Path(_INITIAL_HERMES_HOME).expanduser()
|
||||
return _DEFAULT_HERMES_HOME
|
||||
|
||||
|
||||
def switch_profile(name: str, *, process_wide: bool = True) -> dict:
|
||||
"""Switch the active profile.
|
||||
|
||||
@@ -1711,19 +1784,22 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict:
|
||||
default_workspace = None
|
||||
try:
|
||||
from api.config import DEFAULT_WORKSPACE as _DW
|
||||
from api.workspace import _resolve_path, _remote_terminal_workspace_candidate
|
||||
lw_file = home / 'webui_state' / 'last_workspace.txt'
|
||||
if lw_file.exists():
|
||||
_p = lw_file.read_text(encoding='utf-8').strip()
|
||||
if _p:
|
||||
_pp = Path(_p).expanduser()
|
||||
if _pp.is_dir():
|
||||
default_workspace = str(_pp.resolve())
|
||||
_pp = _resolve_path(_p, profile=name)
|
||||
remote_cand = _remote_terminal_workspace_candidate(_p, profile=name)
|
||||
if remote_cand is not None or _pp.is_dir():
|
||||
default_workspace = str(_pp)
|
||||
if default_workspace is None:
|
||||
for _key in ('workspace', 'default_workspace'):
|
||||
_v = cfg.get(_key)
|
||||
if _v:
|
||||
_pp = Path(str(_v)).expanduser().resolve()
|
||||
if _pp.is_dir():
|
||||
_pp = _resolve_path(str(_v), profile=name)
|
||||
remote_cand = _remote_terminal_workspace_candidate(str(_v), profile=name)
|
||||
if remote_cand is not None or _pp.is_dir():
|
||||
default_workspace = str(_pp)
|
||||
break
|
||||
if default_workspace is None:
|
||||
@@ -1731,8 +1807,9 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict:
|
||||
if isinstance(_tc, dict):
|
||||
_cwd = _tc.get('cwd', '')
|
||||
if _cwd and str(_cwd) not in ('.', ''):
|
||||
_pp = Path(str(_cwd)).expanduser().resolve()
|
||||
if _pp.is_dir():
|
||||
_pp = _resolve_path(str(_cwd), profile=name)
|
||||
remote_cand = _remote_terminal_workspace_candidate(str(_cwd), profile=name)
|
||||
if remote_cand is not None or _pp.is_dir():
|
||||
default_workspace = str(_pp)
|
||||
if default_workspace is None:
|
||||
default_workspace = str(_DW)
|
||||
@@ -1862,7 +1939,19 @@ def _compute_profile_skills_stats(profile_dir: Path) -> tuple[int, int]:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from agent.skill_utils import iter_skill_index_files, parse_frontmatter, skill_matches_platform
|
||||
try:
|
||||
from agent.skill_utils import iter_skill_index_files, parse_frontmatter, skill_matches_platform
|
||||
except ImportError as exc:
|
||||
logger.debug("agent.skill_utils unavailable; reporting skill stats as unknown: %s", exc)
|
||||
# agent source not mounted (two-container Docker,
|
||||
# HERMES_WEBUI_CHAT_BACKEND=gateway): this must never 500 GET
|
||||
# /api/profiles (#7305). Report the skill stats as unknown — a stable
|
||||
# (0, 0) — instead of keeping a partial shadow of agent.skill_utils
|
||||
# here: a local re-implementation cannot preserve the index walk's
|
||||
# exclusions, frontmatter-name identity or platform filtering, so any
|
||||
# count it produced would be inaccurate. The UI omits the skills line
|
||||
# when the total is 0, so the profile picker stays fully usable.
|
||||
return (0, 0)
|
||||
|
||||
seen_names = set()
|
||||
enabled_count = 0
|
||||
@@ -2619,6 +2708,7 @@ def create_profile_api(name: str, clone_from: str = None,
|
||||
model_provider=model_provider,
|
||||
)
|
||||
|
||||
_drop_profile_models_cache(name)
|
||||
# Invalidate cached root-profile-name lookup; create_profile may have added
|
||||
# a new profile that flips is_default semantics on the agent side (#1612).
|
||||
_SKILLS_STATS_CACHE.clear()
|
||||
@@ -2647,6 +2737,16 @@ def create_profile_api(name: str, clone_from: str = None,
|
||||
}
|
||||
|
||||
|
||||
def _drop_profile_models_cache(name: str) -> None:
|
||||
"""A deleted or new profile must never inherit a same-name models snapshot."""
|
||||
from api.config import _get_models_cache_path
|
||||
|
||||
try:
|
||||
_get_models_cache_path(name).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
logger.debug("Failed to drop models cache for profile %s", name, exc_info=True)
|
||||
|
||||
|
||||
def delete_profile_api(name: str) -> dict:
|
||||
"""Delete a profile. Switches to default first if it's the active one.
|
||||
|
||||
@@ -2680,6 +2780,7 @@ def delete_profile_api(name: str) -> dict:
|
||||
else:
|
||||
raise ValueError(f"Profile '{name}' does not exist.")
|
||||
|
||||
_drop_profile_models_cache(name)
|
||||
# Drop cached root-profile-name lookup — list_profiles_api() shape changed.
|
||||
_SKILLS_STATS_CACHE.clear()
|
||||
_invalidate_list_profiles_cache()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Request output independent of agent/tool stdout capture."""
|
||||
|
||||
import os
|
||||
import threading
|
||||
|
||||
|
||||
# Import before agent code. Own the descriptor so replacing or closing Python's
|
||||
# sys.stdout cannot capture access/error records intended for the service log.
|
||||
try:
|
||||
_STREAM = os.fdopen(
|
||||
os.dup(1), "w", encoding="utf-8", errors="backslashreplace", buffering=1,
|
||||
)
|
||||
except OSError:
|
||||
_STREAM = None
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def emit_request_log(message: str) -> None:
|
||||
"""Flush one complete record without letting a broken sink break HTTP."""
|
||||
try:
|
||||
if _STREAM is not None:
|
||||
with _LOCK:
|
||||
_STREAM.write(message + "\n")
|
||||
_STREAM.flush()
|
||||
except Exception:
|
||||
pass
|
||||
+142
-31
@@ -2,6 +2,24 @@
|
||||
|
||||
State-extraction prelude to the routes.py split tracked in #1907.
|
||||
Extracts approval state, not handlers, by design.
|
||||
|
||||
Gateway approvals carry two pieces of state with one lifecycle:
|
||||
|
||||
* the **producer** entry in ``tools.approval._gateway_queues`` — the queued
|
||||
approval itself, which owns the lifecycle; and
|
||||
* the **mirror** entry in ``_pending`` — this module's projection of that
|
||||
producer for the UI.
|
||||
|
||||
A producer is consumed only by an explicit settlement operation, which assigns
|
||||
its result and signals its waiter before fallible projection work begins.
|
||||
``retire_gateway_pending_mirror`` cleans up the UI projection; it must not
|
||||
silently delete a producer, because that would strand the agent thread parked
|
||||
on the producer's event. For a provider-qualified response,
|
||||
``resolve_gateway_pending_run`` matches the exact ``(run_id, approval_id)``
|
||||
pair, settles that producer, and preserves same-run siblings and other runs.
|
||||
When a run becomes terminal, ``settle_gateway_pending_run`` fail-closes every
|
||||
producer owned by that run with ``deny`` before retiring its mirrors; producers
|
||||
owned by another run remain live.
|
||||
"""
|
||||
import queue
|
||||
import threading
|
||||
@@ -265,6 +283,23 @@ def reconcile_gateway_pending_mirror_locked(session_key: str) -> tuple[dict | No
|
||||
live_local_tokens.add(live_entry_token)
|
||||
if not str(live_data.get("approval_id") or "").strip():
|
||||
live_data["approval_id"] = f"gwlocal:{live_entry_token}"
|
||||
|
||||
# A raw id-less LOCAL entry in `_pending` (agent-side _pending_result drops
|
||||
# `{command, pattern_key, pattern_keys, description}` verbatim when no
|
||||
# gateway notifier is registered) is unactionable by contract: the frontend
|
||||
# owner-capture requires an approval_id, so its card can neither be
|
||||
# approved nor dismissed — the poll re-serves it forever. Mint a stable id
|
||||
# on the entry itself (the same contract the producer-tokenization loop
|
||||
# above applies to _gateway_queues producers). Minting on the stored dict
|
||||
# keeps the id stable across polls, so frontend dismiss markers persist.
|
||||
for entry in queue_list:
|
||||
if not isinstance(entry, dict) or _is_gateway_mirror_entry(entry):
|
||||
continue
|
||||
if str(entry.get("approval_id") or "").strip():
|
||||
continue
|
||||
if str(entry.get("run_id") or "").strip():
|
||||
continue
|
||||
entry["approval_id"] = f"gwlocal-mirrorless:{uuid.uuid4().hex}"
|
||||
live_token = (
|
||||
_gateway_mirror_entry_token(live_head_entry)
|
||||
if live_head_entry and live_head_data
|
||||
@@ -488,21 +523,26 @@ def release_gateway_approval_relay_owner(session_key: str, run_id: str, approval
|
||||
_gateway_relay_owners.pop(key, None)
|
||||
|
||||
|
||||
def _settle_gateway_entry(entry, choice: str, reason: str | None = None) -> None:
|
||||
"""Complete a removed producer before any fallible projection work."""
|
||||
entry.result = choice
|
||||
if reason:
|
||||
entry.reason = reason
|
||||
entry.event.set()
|
||||
|
||||
|
||||
def retire_gateway_pending_mirror(
|
||||
session_key: str,
|
||||
approval_id: str = "",
|
||||
run_id: str = "",
|
||||
mirror_token: str = "",
|
||||
) -> bool:
|
||||
"""Retire one approval, or every mirror for a terminal run."""
|
||||
"""Retire one approval mirror, or every mirror for a terminal run."""
|
||||
with _lock:
|
||||
reconcile_gateway_pending_mirror_locked(session_key)
|
||||
queue = _pending.get(session_key)
|
||||
entries = queue if isinstance(queue, list) else [queue] if queue else []
|
||||
normalized_run_id = str(run_id or "").strip()
|
||||
gateway_queue = _gateway_queues.get(session_key) or []
|
||||
retained_gateway_queue = gateway_queue
|
||||
gateway_queue_changed = False
|
||||
if approval_id:
|
||||
match = _gateway_pending_mirror_locked(
|
||||
session_key,
|
||||
@@ -525,15 +565,7 @@ def retire_gateway_pending_mirror(
|
||||
if _is_gateway_mirror_entry(entry)
|
||||
and not str(entry.get("run_id") or "").strip()
|
||||
]
|
||||
if normalized_run_id:
|
||||
retained_gateway_queue = []
|
||||
for entry in gateway_queue:
|
||||
data = getattr(entry, "data", None) or {}
|
||||
if str(data.get("run_id") or "").strip() == normalized_run_id:
|
||||
gateway_queue_changed = True
|
||||
continue
|
||||
retained_gateway_queue.append(entry)
|
||||
if not retired and not gateway_queue_changed:
|
||||
if not retired:
|
||||
head, total, changed = reconcile_gateway_pending_mirror_locked(session_key)
|
||||
_approval_sse_notify_locked(session_key, head, total)
|
||||
if changed:
|
||||
@@ -541,11 +573,6 @@ def retire_gateway_pending_mirror(
|
||||
return changed
|
||||
for match in retired:
|
||||
entries.remove(match)
|
||||
if normalized_run_id and not approval_id:
|
||||
if retained_gateway_queue:
|
||||
_gateway_queues[session_key] = retained_gateway_queue
|
||||
else:
|
||||
_gateway_queues.pop(session_key, None)
|
||||
if entries:
|
||||
_pending[session_key] = entries
|
||||
else:
|
||||
@@ -556,6 +583,60 @@ def retire_gateway_pending_mirror(
|
||||
return True
|
||||
|
||||
|
||||
def settle_gateway_pending_run(
|
||||
session_key: str,
|
||||
run_id: str,
|
||||
*,
|
||||
reason: str,
|
||||
) -> tuple[int, dict | None, int]:
|
||||
"""Fail-close every producer and mirror owned by a terminal gateway run."""
|
||||
run_id = str(run_id or "").strip()
|
||||
if not run_id:
|
||||
return 0, None, 0
|
||||
|
||||
targets = []
|
||||
with _lock:
|
||||
gateway_queue = _gateway_queues.get(session_key) or []
|
||||
retained_gateway_queue = []
|
||||
for entry in gateway_queue:
|
||||
data = getattr(entry, "data", None) or {}
|
||||
if str(data.get("run_id") or "").strip() == run_id:
|
||||
targets.append(entry)
|
||||
else:
|
||||
retained_gateway_queue.append(entry)
|
||||
if retained_gateway_queue:
|
||||
_gateway_queues[session_key] = retained_gateway_queue
|
||||
else:
|
||||
_gateway_queues.pop(session_key, None)
|
||||
|
||||
# Removal and waiter settlement are one semantic operation. In
|
||||
# particular, reconciliation and SSE notification below may raise.
|
||||
for entry in targets:
|
||||
_settle_gateway_entry(entry, "deny", reason)
|
||||
|
||||
queue = _pending.get(session_key)
|
||||
entries = queue if isinstance(queue, list) else [queue] if queue else []
|
||||
retained_pending = [
|
||||
entry
|
||||
for entry in entries
|
||||
if not (
|
||||
_is_gateway_mirror_entry(entry)
|
||||
and str(entry.get("run_id") or "").strip() == run_id
|
||||
)
|
||||
]
|
||||
mirrors_retired = len(retained_pending) != len(entries)
|
||||
if retained_pending:
|
||||
_pending[session_key] = retained_pending
|
||||
else:
|
||||
_pending.pop(session_key, None)
|
||||
|
||||
head, total, changed = reconcile_gateway_pending_mirror_locked(session_key)
|
||||
_approval_sse_notify_locked(session_key, head, total)
|
||||
if targets or mirrors_retired or changed:
|
||||
publish_session_list_changed("attention_resolved")
|
||||
return len(targets), head, total
|
||||
|
||||
|
||||
def _gateway_mirrored_pending_run_id(session_key: str, approval_id: str) -> str | None:
|
||||
"""Compatibility wrapper for exact run-backed lookup."""
|
||||
approval_id = str(approval_id or "").strip()
|
||||
@@ -787,14 +868,50 @@ def resolve_gateway_pending_local(
|
||||
_gateway_queues[session_key] = gateway_queue
|
||||
else:
|
||||
_gateway_queues.pop(session_key, None)
|
||||
if target is not None:
|
||||
_settle_gateway_entry(target, choice, reason)
|
||||
head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key)
|
||||
_approval_sse_notify_locked(session_key, head, total)
|
||||
if target is None:
|
||||
return 0, head, total
|
||||
publish_session_list_changed("attention_resolved")
|
||||
return 1, head, total
|
||||
|
||||
|
||||
def resolve_gateway_pending_run(
|
||||
session_key: str,
|
||||
approval_id: str,
|
||||
run_id: str,
|
||||
choice: str,
|
||||
reason: str | None = None,
|
||||
) -> tuple[int, dict | None, int]:
|
||||
"""Resolve one exact run-backed producer and wake its parked waiter."""
|
||||
target = None
|
||||
approval_id = str(approval_id or "").strip()
|
||||
run_id = str(run_id or "").strip()
|
||||
if not approval_id or not run_id:
|
||||
return 0, None, 0
|
||||
with _lock:
|
||||
gateway_queue = _gateway_queues.get(session_key) or []
|
||||
retained_gateway_queue = []
|
||||
for entry in gateway_queue:
|
||||
data = getattr(entry, "data", None) or {}
|
||||
entry_approval_id = str(data.get("approval_id") or "").strip()
|
||||
entry_run_id = str(data.get("run_id") or "").strip()
|
||||
if target is None and entry_approval_id == approval_id and entry_run_id == run_id:
|
||||
target = entry
|
||||
else:
|
||||
retained_gateway_queue.append(entry)
|
||||
if retained_gateway_queue:
|
||||
_gateway_queues[session_key] = retained_gateway_queue
|
||||
else:
|
||||
_gateway_queues.pop(session_key, None)
|
||||
if target is not None:
|
||||
_settle_gateway_entry(target, choice, reason)
|
||||
head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key)
|
||||
_approval_sse_notify_locked(session_key, head, total)
|
||||
if target is None:
|
||||
return 0, head, total
|
||||
target.result = choice
|
||||
if reason:
|
||||
target.reason = reason
|
||||
target.event.set()
|
||||
publish_session_list_changed("attention_resolved")
|
||||
return 1, head, total
|
||||
|
||||
@@ -839,12 +956,9 @@ def resolve_gateway_pending_local_no_run_mirror(
|
||||
_pending[session_key] = entries
|
||||
else:
|
||||
_pending.pop(session_key, None)
|
||||
_settle_gateway_entry(target, choice, reason)
|
||||
head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key)
|
||||
_approval_sse_notify_locked(session_key, head, total)
|
||||
target.result = choice
|
||||
if reason:
|
||||
target.reason = reason
|
||||
target.event.set()
|
||||
publish_session_list_changed("attention_resolved")
|
||||
return True, 1, head, total
|
||||
|
||||
@@ -887,14 +1001,11 @@ def resolve_gateway_pending_local_all(
|
||||
else:
|
||||
_pending.pop(session_key, None)
|
||||
|
||||
for entry in targets:
|
||||
_settle_gateway_entry(entry, choice, reason)
|
||||
head, total, _changed = reconcile_gateway_pending_mirror_locked(session_key)
|
||||
_approval_sse_notify_locked(session_key, head, total)
|
||||
|
||||
for entry in targets:
|
||||
entry.result = choice
|
||||
if reason:
|
||||
entry.reason = reason
|
||||
entry.event.set()
|
||||
if targets or removed_pending:
|
||||
publish_session_list_changed("attention_resolved")
|
||||
return len(targets), head, total
|
||||
|
||||
@@ -53,6 +53,8 @@ _SIDEBAR_SESSION_RESPONSE_FIELDS = {
|
||||
"model",
|
||||
"model_provider",
|
||||
"message_count",
|
||||
"transcript_generation",
|
||||
"transcript_generation_baseline",
|
||||
"user_message_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
@@ -606,6 +608,22 @@ def _session_list_cache_source_stamp(key: tuple) -> tuple[tuple[int, int], tuple
|
||||
session_index_path = _session_list_cache_session_dir() / "_index.json"
|
||||
except Exception:
|
||||
session_index_path = None
|
||||
all_profiles_state_stamp = None
|
||||
if _cache_all_profiles:
|
||||
try:
|
||||
from api.models import _all_profiles_cli_contexts
|
||||
contexts, _ = _all_profiles_cli_contexts()
|
||||
all_profiles_state_stamp = tuple(
|
||||
(
|
||||
_session_list_cache_path_stamp(ctx_db),
|
||||
_session_list_cache_path_stamp(ctx_db.with_name(f"{ctx_db.name}-wal") if ctx_db else None),
|
||||
_session_list_cache_state_db_fingerprint(ctx_db),
|
||||
)
|
||||
for _home, ctx_db, _prof in contexts
|
||||
)
|
||||
except Exception:
|
||||
all_profiles_state_stamp = None
|
||||
|
||||
return (
|
||||
_session_list_cache_path_stamp(state_db_path),
|
||||
_session_list_cache_path_stamp(state_db_wal_path),
|
||||
@@ -617,6 +635,7 @@ def _session_list_cache_source_stamp(key: tuple) -> tuple[tuple[int, int], tuple
|
||||
# frame size), so without this a freshly-committed CLI/gateway session
|
||||
# could be served stale for the cache TTL. Mirrors the models-layer fix.
|
||||
_session_list_cache_state_db_fingerprint(state_db_path),
|
||||
all_profiles_state_stamp,
|
||||
swv,
|
||||
)
|
||||
|
||||
|
||||
+1417
-335
File diff suppressed because it is too large.
Load diff
+93
-11
@@ -10,6 +10,7 @@ import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
@@ -45,6 +46,14 @@ TERMINAL_SSE_EVENTS = frozenset({"done", "cancel", "apperror", "error", "stream_
|
||||
SSE_RELAY_CLOSE_EVENTS = frozenset({"stream_end", "cancel", "apperror", "error"})
|
||||
# Back-compat alias used by older call sites / tests.
|
||||
_TERMINAL_SSE_EVENTS = TERMINAL_SSE_EVENTS
|
||||
# Events that are live-UI-only telemetry with no recovery value in the run
|
||||
# journal. They are skipped at WRITE time (never durably journaled, so they
|
||||
# cannot bloat the journal on marathon runs) and filtered at REPLAY time (so
|
||||
# legacy journals that already contain a backlog never stream it to a
|
||||
# reconnecting browser tab). Readers deliberately do NOT filter: cursor math
|
||||
# (``cursor_event_missing`` bound) and the offline-gap coverage check count
|
||||
# journal seqs and must keep seeing every row.
|
||||
REPLAY_SKIPPED_SSE_EVENTS = frozenset({"metering"})
|
||||
_FSYNC_MODE_ENV = "HERMES_WEBUI_RUN_JOURNAL_FSYNC"
|
||||
_FSYNC_MODE_EAGER = "eager"
|
||||
_FSYNC_MODE_TERMINAL_ONLY = "terminal-only"
|
||||
@@ -122,7 +131,7 @@ def _get_cached_summary(path: Path) -> dict | None:
|
||||
_SUMMARY_CACHE.pop(key, None)
|
||||
return None
|
||||
_SUMMARY_CACHE.move_to_end(key)
|
||||
return dict(summary)
|
||||
return deepcopy(summary)
|
||||
|
||||
|
||||
def _cache_summary(
|
||||
@@ -139,7 +148,7 @@ def _cache_summary(
|
||||
return
|
||||
key = str(path)
|
||||
with _SUMMARY_CACHE_LOCK:
|
||||
_SUMMARY_CACHE[key] = (signature, dict(summary))
|
||||
_SUMMARY_CACHE[key] = (signature, deepcopy(summary))
|
||||
_SUMMARY_CACHE.move_to_end(key)
|
||||
while len(_SUMMARY_CACHE) > _SUMMARY_CACHE_MAX_ENTRIES:
|
||||
_SUMMARY_CACHE.popitem(last=False)
|
||||
@@ -443,25 +452,45 @@ class RunJournalWriter:
|
||||
self.session_id = _validate_id(session_id, "session_id")
|
||||
self.run_id = _validate_id(run_id, "run_id")
|
||||
self.session_dir = Path(session_dir) if session_dir is not None else None
|
||||
self._path = _run_path(self.session_id, self.run_id, session_dir=self.session_dir)
|
||||
self._lock = _lock_for(self._path)
|
||||
|
||||
def append_sse_event(self, event_name: str, payload=None) -> dict:
|
||||
# Draw from the shared module-level seq cache under the per-path lock so
|
||||
# this writer and any direct append_run_event() call on the same path
|
||||
# agree on one monotonic, gapless sequence.
|
||||
with self._lock:
|
||||
seq = _reserve_next_seq(self._path)
|
||||
def append_sse_event(self, event_name: str, payload=None) -> dict | None:
|
||||
# Live-UI-only telemetry (metering) has no recovery value in the journal:
|
||||
# nothing reads those rows back for recovery, and journaling them at ~10 Hz
|
||||
# on marathon runs balloons the durable file (12+ MB of a single 18 MB run
|
||||
# was metering). Skip the write entirely and return None so callers'
|
||||
# journal-id plumbing (``(journaled or {}).get("event_id")``) is untouched.
|
||||
# Not reserving a seq keeps the remaining journaled seqs contiguous, which
|
||||
# the offline-gap coverage and replay-cursor contiguity checks rely on.
|
||||
if str(event_name or "").strip() in REPLAY_SKIPPED_SSE_EVENTS:
|
||||
return None
|
||||
# Allocate the sequence inside the same per-path transaction that writes
|
||||
# the row. Reserving here, then releasing the lock before append, lets a
|
||||
# concurrent writer put a higher sequence on disk first.
|
||||
return append_run_event(
|
||||
self.session_id,
|
||||
self.run_id,
|
||||
event_name,
|
||||
payload or {},
|
||||
session_dir=self.session_dir,
|
||||
seq=seq,
|
||||
)
|
||||
|
||||
|
||||
def journal_replay_visible(event) -> bool:
|
||||
"""Return True when a journal row should be streamed to a reconnecting tab.
|
||||
|
||||
Live-UI-only telemetry rows (see ``REPLAY_SKIPPED_SSE_EVENTS``) carry no
|
||||
recovery value — replaying a metering backlog only re-paints a stale TPS
|
||||
number while multiplying the reconnect burst size. Writers no longer journal
|
||||
them, but legacy journals may already contain them, so the replay emit sites
|
||||
filter through this predicate. Non-dict rows are passed through (visible) so
|
||||
an unexpected shape can never silently swallow user-visible output.
|
||||
"""
|
||||
if not isinstance(event, dict):
|
||||
return True
|
||||
name = str(event.get("event") or event.get("type") or "")
|
||||
return name not in REPLAY_SKIPPED_SSE_EVENTS
|
||||
|
||||
|
||||
def read_run_events(
|
||||
session_id: str,
|
||||
run_id: str,
|
||||
@@ -506,6 +535,37 @@ def select_authoritative_terminal_event(events: Iterable[dict]) -> dict | None:
|
||||
)
|
||||
|
||||
|
||||
def runtime_model_from_events(session_id: str, stream_id: str, events: Iterable[dict]) -> dict | None:
|
||||
"""Project only observed serving identity for this journal owner.
|
||||
|
||||
A fallback warning or malformed later observation invalidates old evidence;
|
||||
the configured selection and free-text status never supply serving identity.
|
||||
"""
|
||||
observed = None
|
||||
for event in events:
|
||||
if not isinstance(event, dict) or event.get("session_id") != session_id or event.get("run_id") != stream_id:
|
||||
continue
|
||||
payload = event.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
if event.get("event") == "runtime_model":
|
||||
observed = None
|
||||
continue
|
||||
if event.get("event") == "warning" and payload.get("type") == "fallback":
|
||||
observed = None
|
||||
elif event.get("event") == "runtime_model":
|
||||
observed = None
|
||||
if (payload.get("session_id") != session_id or payload.get("stream_id") != stream_id
|
||||
or not isinstance(payload.get("model"), str) or not payload["model"].strip()
|
||||
or not isinstance(payload.get("fallback_active"), bool)
|
||||
or payload.get("phase") not in ("observed_output", "route_observed")):
|
||||
continue
|
||||
observed = {key: payload[key] for key in (
|
||||
"session_id", "stream_id", "model", "fallback_active", "phase")}
|
||||
if isinstance(payload.get("provider"), str) and payload["provider"].strip():
|
||||
observed["provider"] = payload["provider"]
|
||||
return observed
|
||||
|
||||
|
||||
def _summary_from_events(session_id: str, run_id: str, events: Iterable[dict]) -> dict:
|
||||
ordered = [event for event in events if isinstance(event, dict)]
|
||||
last = ordered[-1] if ordered else None
|
||||
@@ -521,6 +581,7 @@ def _summary_from_events(session_id: str, run_id: str, events: Iterable[dict]) -
|
||||
"terminal": bool(terminal),
|
||||
"terminal_state": status,
|
||||
"last_event": (last or {}).get("event"),
|
||||
"runtime_model": runtime_model_from_events(session_id, run_id, ordered),
|
||||
}
|
||||
|
||||
|
||||
@@ -587,6 +648,27 @@ def find_run_summary(run_id: str, *, session_dir: Path | None = None) -> dict |
|
||||
return None
|
||||
|
||||
|
||||
def find_run_file(run_id: str, *, session_dir: Path | None = None) -> tuple[str, Path] | None:
|
||||
"""Locate a run journal file by run id WITHOUT parsing its body.
|
||||
|
||||
Hot callers that immediately read the full journal (the live-snapshot
|
||||
rebuild) must not pay :func:`find_run_summary`'s full-file parse first:
|
||||
on a long live run the file holds tens of thousands of rows and parsing
|
||||
it twice per rebuild dominated the snapshot cost. Returns
|
||||
``(session_id, path)`` for the first match, or ``None`` when the run id
|
||||
is invalid or no journal exists.
|
||||
"""
|
||||
try:
|
||||
rid = _validate_id(run_id, "run_id")
|
||||
except ValueError:
|
||||
return None
|
||||
root = Path(session_dir) if session_dir is not None else _default_session_dir()
|
||||
journal_root = root / RUN_JOURNAL_DIR_NAME
|
||||
for path in journal_root.glob(f"*/{rid}.jsonl"):
|
||||
return path.parent.name, path
|
||||
return None
|
||||
|
||||
|
||||
def read_session_run_events(
|
||||
session_id: str,
|
||||
*,
|
||||
|
||||
@@ -608,6 +608,8 @@ def _stamp_intentional_shrink_generation(session, old_message_count: int, new_me
|
||||
if new_message_count >= old_message_count:
|
||||
return False
|
||||
session.intentional_shrink_generation = uuid.uuid4().hex
|
||||
session.transcript_generation = max(0, int(getattr(session, 'transcript_generation', 0) or 0)) + 1
|
||||
session.transcript_generation_baseline = max(0, int(new_message_count))
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -601,7 +601,14 @@ def _state_db_row_to_sidecar(row: dict) -> dict:
|
||||
'session_id': row.get('id'),
|
||||
'title': row.get('title') or 'Recovered WebUI Session',
|
||||
'workspace': workspace_value if isinstance(workspace_value, str) else '',
|
||||
'message_count': row.get('message_count') if isinstance(row.get('message_count'), int) else len(messages),
|
||||
# state.db is canonical for the recovered message rows. Its denormalized
|
||||
# sessions.message_count is absent from older schemas (read above as 0)
|
||||
# and can be stale after recovery, so never copy it into a sidecar whose
|
||||
# `messages` array we just materialized. Session.save() uses this field
|
||||
# to decide whether an overwrite shrinks the transcript; it must match
|
||||
# the payload exactly or the #1558 backup / empty-snapshot guards can be
|
||||
# bypassed.
|
||||
'message_count': len(messages if isinstance(messages, list) else []),
|
||||
'worktree_path': row.get('worktree_path') or None,
|
||||
'worktree_branch': row.get('worktree_branch') or None,
|
||||
'worktree_repo_root': row.get('worktree_repo_root') or None,
|
||||
|
||||
+1565
-366
File diff suppressed because it is too large.
Load diff
+112
-24
@@ -8,7 +8,12 @@ import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from api.config import MAX_UPLOAD_BYTES, STATE_DIR
|
||||
from api.helpers import j
|
||||
from api.helpers import (
|
||||
arm_connection_close_if_body_pending,
|
||||
j,
|
||||
unreadable_content_length,
|
||||
unsupported_transfer_encoding,
|
||||
)
|
||||
from api.models import get_session
|
||||
from api.profiles import _profiles_match, get_active_profile_name as _get_active_profile_name
|
||||
from api.workspace import (
|
||||
@@ -45,6 +50,89 @@ def _max_extracted_bytes() -> int:
|
||||
_MAX_EXTRACTED_BYTES = 10 * MAX_UPLOAD_BYTES
|
||||
|
||||
|
||||
def _reject_before_read(handler, message, status):
|
||||
"""Reject without reading the body; close so unread bytes can't poison HTTP/1.1 reuse.
|
||||
|
||||
Framing decides, exactly as at the other reject-before-read sites: every
|
||||
rejection raised by the preflight above (any ``Transfer-Encoding``, an
|
||||
unreadable or conflicting ``Content-Length``, an over-cap length) already
|
||||
declares a body and still closes. Only the boundary/`parse_multipart` rejection
|
||||
can fire on a request that declares none, and closing there dropped a healthy
|
||||
pooled connection -- on the wire, ``POST /api/upload`` with
|
||||
``Content-Type: application/json`` and no ``Content-Length`` answered 400
|
||||
"No boundary in Content-Type" WITH ``Connection: close`` and the pipelined
|
||||
``GET /api/health/agent`` was never served.
|
||||
"""
|
||||
arm_connection_close_if_body_pending(handler)
|
||||
return j(handler, {'error': message}, status=status)
|
||||
|
||||
|
||||
class _RejectBeforeRead(ValueError):
|
||||
"""A rejection raised before any byte of the request body was consumed.
|
||||
|
||||
Carries the status the caller must answer with. Subclasses ``ValueError`` so
|
||||
that if one ever escapes an unusual call path, each handler's existing
|
||||
``except ValueError`` still answers 400 with the same message instead of
|
||||
turning it into a 500.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, status: int):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
def _read_multipart_or_reject(handler):
|
||||
"""Framing preflight + body read, shared by all four multipart handlers.
|
||||
|
||||
Returns ``(fields, files)``. Raises ``_RejectBeforeRead`` when the request
|
||||
must be refused before its body is read -- the caller answers through
|
||||
``_reject_before_read()``, which arms ``Connection: close`` first, because the
|
||||
declared body is still queued in ``rfile`` and a reused HTTP/1.1 connection
|
||||
would parse those bytes as the next request line.
|
||||
|
||||
Framing is judged from the headers, not from a present ``Content-Length``:
|
||||
|
||||
* ANY ``Transfer-Encoding`` is refused, ``identity`` included: ``http.server``
|
||||
decodes none of them (``rfile`` is the raw socket) and RFC 9112 section 6.3
|
||||
says a request whose final coding is not ``chunked`` has a length no
|
||||
recipient can determine. A coded request carries no usable Content-Length,
|
||||
so the old code read it as length 0, found no parts, and answered "No file
|
||||
field in request" with keep-alive intact -- leaving every payload byte on
|
||||
the socket to poison the next pooled request. A Content-Length sent BESIDE a
|
||||
coding does not rescue it: that pairing is the smuggling shape 6.3 rejects,
|
||||
and the coding still hides how the body is framed.
|
||||
* a non-numeric or oversized Content-Length cannot be read either, and
|
||||
``parse_multipart()`` validates the boundary and the length before its
|
||||
first ``rfile.read()`` — so every raise below is still pre-read.
|
||||
* two DISAGREEING Content-Length headers read back as whichever came first,
|
||||
so ``Content-Length: 0`` ahead of the real length made the body invisible
|
||||
and left every byte of it on the socket. There is no honest length to
|
||||
drain, so the request is refused before ``rfile`` is touched.
|
||||
"""
|
||||
content_type = handler.headers.get('Content-Type', '')
|
||||
coding = unsupported_transfer_encoding(handler)
|
||||
if coding is not None:
|
||||
raise _RejectBeforeRead(
|
||||
f'Unsupported Transfer-Encoding: {coding} '
|
||||
'(uploads must send an undecoded body with a Content-Length)',
|
||||
411,
|
||||
)
|
||||
if unreadable_content_length(handler):
|
||||
raise _RejectBeforeRead('Invalid Content-Length', 400)
|
||||
try:
|
||||
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
raise _RejectBeforeRead('Invalid Content-Length', 400) from None
|
||||
if content_length > MAX_UPLOAD_BYTES:
|
||||
raise _RejectBeforeRead(
|
||||
f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)', 413)
|
||||
try:
|
||||
return parse_multipart(handler.rfile, content_type, content_length)
|
||||
except ValueError as exc:
|
||||
raise _RejectBeforeRead(str(exc), 400) from None
|
||||
|
||||
|
||||
def parse_multipart(rfile, content_type, content_length) -> tuple:
|
||||
import re as _re, email.parser as _ep
|
||||
# Imported locally (not just module-level) so the function stays
|
||||
@@ -206,11 +294,10 @@ def _write_office_upload_sidecar(workspace: Path, dest: Path, file_bytes: bytes)
|
||||
def handle_upload(handler):
|
||||
import traceback as _tb
|
||||
try:
|
||||
content_type = handler.headers.get('Content-Type', '')
|
||||
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
||||
if content_length > MAX_UPLOAD_BYTES:
|
||||
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
|
||||
fields, files = parse_multipart(handler.rfile, content_type, content_length)
|
||||
try:
|
||||
fields, files = _read_multipart_or_reject(handler)
|
||||
except _RejectBeforeRead as rejection:
|
||||
return _reject_before_read(handler, rejection.message, rejection.status)
|
||||
session_id = fields.get('session_id', '')
|
||||
if 'file' not in files:
|
||||
return j(handler, {'error': 'No file field in request'}, status=400)
|
||||
@@ -396,11 +483,10 @@ def handle_upload_extract(handler):
|
||||
"""Handle archive upload and extraction."""
|
||||
import traceback as _tb
|
||||
try:
|
||||
content_type = handler.headers.get('Content-Type', '')
|
||||
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
||||
if content_length > MAX_UPLOAD_BYTES:
|
||||
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
|
||||
fields, files = parse_multipart(handler.rfile, content_type, content_length)
|
||||
try:
|
||||
fields, files = _read_multipart_or_reject(handler)
|
||||
except _RejectBeforeRead as rejection:
|
||||
return _reject_before_read(handler, rejection.message, rejection.status)
|
||||
session_id = fields.get('session_id', '')
|
||||
if 'file' not in files:
|
||||
return j(handler, {'error': 'No file field in request'}, status=400)
|
||||
@@ -428,11 +514,10 @@ def handle_transcribe(handler):
|
||||
import traceback as _tb
|
||||
temp_path = None
|
||||
try:
|
||||
content_type = handler.headers.get('Content-Type', '')
|
||||
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
||||
if content_length > MAX_UPLOAD_BYTES:
|
||||
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
|
||||
fields, files = parse_multipart(handler.rfile, content_type, content_length)
|
||||
try:
|
||||
fields, files = _read_multipart_or_reject(handler)
|
||||
except _RejectBeforeRead as rejection:
|
||||
return _reject_before_read(handler, rejection.message, rejection.status)
|
||||
if 'file' not in files:
|
||||
return j(handler, {'error': 'No file field in request'}, status=400)
|
||||
filename, file_bytes = files['file']
|
||||
@@ -612,12 +697,10 @@ def handle_workspace_upload(handler):
|
||||
"""
|
||||
import traceback as _tb
|
||||
try:
|
||||
content_type = handler.headers.get('Content-Type', '')
|
||||
content_length = int(handler.headers.get('Content-Length', 0) or 0)
|
||||
if content_length > MAX_UPLOAD_BYTES:
|
||||
return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
|
||||
|
||||
fields, files = parse_multipart(handler.rfile, content_type, content_length)
|
||||
try:
|
||||
fields, files = _read_multipart_or_reject(handler)
|
||||
except _RejectBeforeRead as rejection:
|
||||
return _reject_before_read(handler, rejection.message, rejection.status)
|
||||
session_id = fields.get('session_id', '')
|
||||
subpath = fields.get('path', '')
|
||||
|
||||
@@ -635,8 +718,13 @@ def handle_workspace_upload(handler):
|
||||
if _reject_invisible_session(handler, session):
|
||||
return True
|
||||
|
||||
# Resolve workspace root from session
|
||||
workspace = resolve_trusted_workspace(session.workspace)
|
||||
# Resolve workspace root using the session profile, not the ambient request profile.
|
||||
try:
|
||||
workspace = resolve_trusted_workspace(
|
||||
session.workspace, profile=getattr(session, "profile", None)
|
||||
)
|
||||
except TypeError:
|
||||
workspace = resolve_trusted_workspace(session.workspace)
|
||||
|
||||
# Resolve target subdirectory within workspace
|
||||
target_dir = safe_resolve_ws(workspace, subpath) if subpath else workspace
|
||||
|
||||
@@ -57,6 +57,8 @@ _METADATA_FIELDS = frozenset(
|
||||
"compression_recovery_action",
|
||||
"truncation_watermark",
|
||||
"truncation_boundary",
|
||||
"transcript_generation",
|
||||
"transcript_generation_baseline",
|
||||
"gateway_routing",
|
||||
"gateway_routing_history",
|
||||
"llm_title_generated",
|
||||
|
||||
+369
-71
@@ -12,12 +12,15 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import concurrent.futures
|
||||
import contextlib
|
||||
import contextvars
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
@@ -40,8 +43,14 @@ from api.subprocess_utils import windows_hide_flags
|
||||
|
||||
# ── Profile-aware path resolution ───────────────────────────────────────────
|
||||
|
||||
def _profile_state_dir() -> Path:
|
||||
"""Return the webui_state directory for the active profile.
|
||||
# Logical profile-name grammar — mirrors api.profiles._PROFILE_ID_RE. Kept as
|
||||
# a local copy so workspace-layer validation does not import profiles at module
|
||||
# load time (profiles may not be importable in every embedding context).
|
||||
_PROFILE_NAME_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$')
|
||||
|
||||
|
||||
def _profile_state_dir(profile: str | Path | None = None) -> Path:
|
||||
"""Return the webui_state directory for the active or given profile.
|
||||
|
||||
For the default profile, returns the global STATE_DIR (respects
|
||||
HERMES_WEBUI_STATE_DIR env var for test isolation).
|
||||
@@ -49,6 +58,28 @@ def _profile_state_dir() -> Path:
|
||||
"""
|
||||
try:
|
||||
from api.profiles import get_active_profile_name, get_active_hermes_home
|
||||
if profile is not None:
|
||||
# Literal-"default" STATE routing (#7168 re-gate round 7): the
|
||||
# default profile's workspace state always lives in the global
|
||||
# state files, even when isolated mode pins the default home at
|
||||
# <base>/profiles/default. The round-6 resolver change made
|
||||
# _resolve_profile_home_param("default") return that pinned home,
|
||||
# so the canonical-home check below sent explicit
|
||||
# profile="default" state reads/writes to {pinned}/webui_state/
|
||||
# while ambient calls kept using the global dir — splitting saved
|
||||
# workspaces between two authorities. Config and workspace PATH
|
||||
# resolution still use the pinned home via
|
||||
# _resolve_profile_home_param; only this state-file tier stays
|
||||
# global for the logical string "default".
|
||||
if isinstance(profile, str) and profile.strip() == 'default':
|
||||
return _GLOBAL_WS_FILE.parent
|
||||
profile_home = _resolve_profile_home_param(profile)
|
||||
if not _is_default_profile_home(profile_home):
|
||||
d = profile_home / 'webui_state'
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
return _GLOBAL_WS_FILE.parent
|
||||
|
||||
name = get_active_profile_name()
|
||||
if name and name != 'default':
|
||||
d = get_active_hermes_home() / 'webui_state'
|
||||
@@ -59,14 +90,41 @@ def _profile_state_dir() -> Path:
|
||||
return _GLOBAL_WS_FILE.parent
|
||||
|
||||
|
||||
def _workspaces_file() -> Path:
|
||||
"""Return the workspaces.json path for the active profile."""
|
||||
return _profile_state_dir() / 'workspaces.json'
|
||||
def _workspaces_file(profile: str | Path | None = None) -> Path:
|
||||
"""Return the workspaces.json path for the active or given profile."""
|
||||
return _profile_state_dir(profile=profile) / 'workspaces.json'
|
||||
|
||||
|
||||
def _last_workspace_file() -> Path:
|
||||
"""Return the last_workspace.txt path for the active profile."""
|
||||
return _profile_state_dir() / 'last_workspace.txt'
|
||||
def _last_workspace_file(profile: str | Path | None = None) -> Path:
|
||||
"""Return the last_workspace.txt path for the active or given profile."""
|
||||
return _profile_state_dir(profile=profile) / 'last_workspace.txt'
|
||||
|
||||
|
||||
def _workspaces_file_for_profile(profile: str | Path | None = None) -> Path | None:
|
||||
"""Profile-scoped workspaces.json path, or None for an INVALID profile.
|
||||
|
||||
``None`` is the fail-closed contract (#7168 re-gate round 4): a malformed
|
||||
profile name must not be clamped onto the default/global state files, so
|
||||
callers treat it as "no readable/writable profile-local state".
|
||||
"""
|
||||
try:
|
||||
return _workspaces_file(profile=profile) if profile is not None else _workspaces_file()
|
||||
except TypeError:
|
||||
return _workspaces_file()
|
||||
except ValueError:
|
||||
logger.debug("Ignoring invalid profile name %r for workspaces file", profile)
|
||||
return None
|
||||
|
||||
|
||||
def _last_workspace_file_for_profile(profile: str | Path | None = None) -> Path | None:
|
||||
"""Profile-scoped last_workspace.txt path, or None for an INVALID profile."""
|
||||
try:
|
||||
return _last_workspace_file(profile=profile) if profile is not None else _last_workspace_file()
|
||||
except TypeError:
|
||||
return _last_workspace_file()
|
||||
except ValueError:
|
||||
logger.debug("Ignoring invalid profile name %r for last-workspace file", profile)
|
||||
return None
|
||||
|
||||
|
||||
def _expanduser_path(path: str | Path) -> Path:
|
||||
@@ -101,8 +159,11 @@ def _expanduser_path(path: str | Path) -> Path:
|
||||
return Path(raw)
|
||||
|
||||
|
||||
def _resolve_path(path: str | Path) -> Path:
|
||||
"""Resolve *path* after env-aware home expansion, without raising."""
|
||||
def _resolve_path(path: str | Path, profile: str | Path | None = None) -> Path:
|
||||
"""Resolve *path* after env-aware home expansion, preserving remote POSIX paths without raising."""
|
||||
remote_candidate = _remote_terminal_workspace_candidate(path, profile=profile)
|
||||
if remote_candidate is not None:
|
||||
return remote_candidate
|
||||
return _safe_resolve(_expanduser_path(path))
|
||||
|
||||
|
||||
@@ -148,12 +209,149 @@ def _is_remote_terminal_backend(terminal_cfg: dict | None) -> bool:
|
||||
return backend not in ('', 'local')
|
||||
|
||||
|
||||
def _remote_terminal_cwd() -> str | None:
|
||||
"""Return target-side terminal cwd for remote profiles, without local stat()."""
|
||||
# Call-scoped cache for `_resolve_profile_home_param`'s filesystem
|
||||
# `.resolve()` call. The webui's session list builds one `Session` per CLI
|
||||
# session row (`_load_cli_sessions_uncached`), and `Session.__init__` calls
|
||||
# `_resolve_profile_home_param(profile)` once per row -- with only one
|
||||
# profile in play (the common case) that is the SAME path resolved hundreds
|
||||
# of times per request. Individually a resolve() is ~1ms, but at a few
|
||||
# hundred sessions this compounded into multi-second sidebar hangs under
|
||||
# host load (confirmed via repeated "Slow WebUI request still running"
|
||||
# warnings pinned to this call site).
|
||||
#
|
||||
# IMPORTANT: this is deliberately NOT a process-lifetime cache. An earlier
|
||||
# revision of this fix cached forever, on the premise that a profile
|
||||
# argument always resolves to the same path for the life of the process --
|
||||
# but `Path.resolve()` is a filesystem call, not a pure function of
|
||||
# startup constants: a profile-home symlink can be retargeted while the
|
||||
# webui process keeps running for days/weeks, and `_safe_resolve()` below
|
||||
# deliberately falls back to returning the UNRESOLVED input after a
|
||||
# transient `OSError`/`RuntimeError`/`ValueError`, which a process-lifetime
|
||||
# cache would then serve forever. Both are real correctness bugs (caught in
|
||||
# PR #7636 review), not style nitpicks.
|
||||
#
|
||||
# Instead the cache is scoped to exactly ONE invocation of the hot loop
|
||||
# (`_load_cli_sessions_uncached`, wrapped below in `profile_home_resolve_cache_scope`)
|
||||
# via a `ContextVar` that holds a fresh dict only while that single
|
||||
# sidebar-list build is running, and is `None` everywhere else. Every other
|
||||
# call site -- and every request that isn't actively inside that one
|
||||
# build -- resolves fresh every time, exactly like pre-PR behavior, so a
|
||||
# symlink retarget or a transient resolve error is never masked past the
|
||||
# single call that would have paid for the redundant resolves anyway.
|
||||
_PROFILE_HOME_RESOLVE_SCOPE: "contextvars.ContextVar[dict[Path, Path] | None]" = (
|
||||
contextvars.ContextVar("_PROFILE_HOME_RESOLVE_SCOPE", default=None)
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def profile_home_resolve_cache_scope():
|
||||
"""Enable memoization of `_resolve_profile_home_param` for one call.
|
||||
|
||||
Use as a decorator (or a `with` block) around the single hot-path
|
||||
operation that resolves the SAME profile argument hundreds of times in a
|
||||
tight loop -- currently `_load_cli_sessions_uncached` building the
|
||||
CLI/cron session list. While active, repeated resolves of the same
|
||||
profile argument are served from a dict that lives ONLY for the
|
||||
duration of the call. Outside of it -- including before/after, and any
|
||||
call site other than the one wrapped -- `_resolve_profile_home_param`
|
||||
resolves fresh every time, with no caching, matching pre-PR behavior
|
||||
exactly. Nested/reentrant use restores the previous (outer) value on
|
||||
exit rather than clobbering it, so this is safe to nest.
|
||||
"""
|
||||
token = _PROFILE_HOME_RESOLVE_SCOPE.set({})
|
||||
try:
|
||||
from api.config import get_config
|
||||
yield
|
||||
finally:
|
||||
_PROFILE_HOME_RESOLVE_SCOPE.reset(token)
|
||||
|
||||
|
||||
def _cached_safe_resolve_profile_home(pre_resolve: Path) -> Path:
|
||||
"""`_safe_resolve()` for profile-home paths.
|
||||
|
||||
Memoized only while a `profile_home_resolve_cache_scope()` is active
|
||||
(see module doc above); otherwise resolves fresh every call, matching
|
||||
pre-PR behavior.
|
||||
"""
|
||||
cache = _PROFILE_HOME_RESOLVE_SCOPE.get()
|
||||
if cache is None:
|
||||
return _safe_resolve(pre_resolve)
|
||||
cached = cache.get(pre_resolve)
|
||||
if cached is not None:
|
||||
return cached
|
||||
resolved = _safe_resolve(pre_resolve)
|
||||
cache[pre_resolve] = resolved
|
||||
return resolved
|
||||
|
||||
|
||||
def _resolve_profile_home_param(profile: str | Path | None) -> Path:
|
||||
"""Resolve a profile parameter (name string, directory path string, or Path) to a profile home Path.
|
||||
|
||||
Logical profile names are validated strictly (#7168 re-gate round 4): a
|
||||
name that fails the profile-id grammar raises ValueError instead of being
|
||||
silently clamped onto the default home — the old clamp let a malformed
|
||||
name such as ``"bad name"`` read/write the DEFAULT profile's state.
|
||||
Round 5 tightens the grammar gate: a STRING profile value is strictly a
|
||||
logical profile id and is NEVER treated as a path-shaped home — the old
|
||||
``"/" in raw`` branch resolved any slash-bearing string directly, so a
|
||||
malformed value like ``"../evil"`` bypassed validation entirely and could
|
||||
read/overwrite an arbitrary ``webui_state/last_workspace.txt``
|
||||
(#7168 re-gate round 5, path traversal on the profile-isolation boundary).
|
||||
Round 6 closes the last isolation hole in this resolver: the logical
|
||||
string ``"default"`` used to short-circuit to ``_DEFAULT_HERMES_HOME``
|
||||
before reaching ``get_hermes_home_for_profile()``, bypassing the
|
||||
isolated-mode clamp in ``api.profiles._resolve_profile_home_for_name``
|
||||
(#7168 re-gate round 6). In an isolated deployment pinned at
|
||||
``<base>/profiles/default``, a session created with ``profile="default"``
|
||||
therefore resolved workspace/config from the BASE root home instead of
|
||||
the pinned one. The name now flows through the same delegated path as
|
||||
every other logical id; literal-default routing to the global state
|
||||
files is retained in ``_profile_state_dir``/``get_last_workspace`` via
|
||||
canonical ``_is_default_profile_home`` identity.
|
||||
An explicit home directory is expressed as a ``Path`` object (callers such
|
||||
as streaming's legacy ``_profile_home`` fallback wrap their home strings
|
||||
in ``Path``); Path values are honored and canonicalized so identity
|
||||
comparisons never depend on lexical spelling (e.g. a symlink alias of the
|
||||
default home must compare equal to it).
|
||||
"""
|
||||
if profile is None or str(profile).strip() == "":
|
||||
from api.profiles import get_active_hermes_home
|
||||
return get_active_hermes_home()
|
||||
|
||||
raw = str(profile).strip()
|
||||
|
||||
if isinstance(profile, Path):
|
||||
return _cached_safe_resolve_profile_home(profile.expanduser())
|
||||
|
||||
# Strings are LOGICAL PROFILE IDS ONLY — no path-shaped strings, ever.
|
||||
if not _PROFILE_NAME_RE.fullmatch(raw):
|
||||
raise ValueError(f"invalid profile name: {raw!r}")
|
||||
|
||||
from api.profiles import get_hermes_home_for_profile
|
||||
return _cached_safe_resolve_profile_home(get_hermes_home_for_profile(raw))
|
||||
|
||||
|
||||
def _is_default_profile_home(profile_home: Path) -> bool:
|
||||
"""Canonical identity check against the root/default Hermes home.
|
||||
|
||||
Compares resolved paths so a symlink alias of _DEFAULT_HERMES_HOME is
|
||||
recognized as the default profile rather than treated as a foreign,
|
||||
lexically-different directory (#7168 re-gate round 4).
|
||||
"""
|
||||
try:
|
||||
from api.profiles import _DEFAULT_HERMES_HOME
|
||||
return _safe_resolve(profile_home) == _safe_resolve(_DEFAULT_HERMES_HOME)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _remote_terminal_cwd(profile: str | Path | None = None) -> str | None:
|
||||
"""Return target-side terminal cwd for a remote profile, without local stat()."""
|
||||
try:
|
||||
from api.config import get_config_for_profile_home
|
||||
|
||||
profile_home = _resolve_profile_home_param(profile)
|
||||
terminal_cfg = get_config_for_profile_home(profile_home).get('terminal', {})
|
||||
|
||||
terminal_cfg = get_config().get('terminal', {})
|
||||
if not _is_remote_terminal_backend(terminal_cfg):
|
||||
return None
|
||||
cwd = str(terminal_cfg.get('cwd') or '').strip()
|
||||
@@ -165,8 +363,8 @@ def _remote_terminal_cwd() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None:
|
||||
"""Return a non-stat'ed target-side Path when it is under terminal.cwd.
|
||||
def _remote_terminal_workspace_candidate(path: str | Path, profile: str | Path | None = None) -> Path | None:
|
||||
"""Return a non-stat'ed target-side Path when it is under terminal.cwd for the given profile.
|
||||
|
||||
Remote workspace paths live on the target host (e.g., remote SSH/Docker
|
||||
backend). For valid target-side POSIX paths under ``terminal.cwd``, the
|
||||
@@ -174,7 +372,10 @@ def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None:
|
||||
local host-filesystem resolution (avoiding host-specific firmlink rewriting
|
||||
such as macOS synthetic ``/home`` -> ``/System/Volumes/Data/home``).
|
||||
"""
|
||||
cwd = _remote_terminal_cwd()
|
||||
try:
|
||||
cwd = _remote_terminal_cwd(profile=profile) if profile is not None else _remote_terminal_cwd()
|
||||
except TypeError:
|
||||
cwd = _remote_terminal_cwd()
|
||||
if not cwd:
|
||||
return None
|
||||
raw = _strip_surrounding_quotes(str(path)).strip()
|
||||
@@ -192,8 +393,8 @@ def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None:
|
||||
if posix_candidate == posix_base or _posix_is_within(posix_candidate, posix_base):
|
||||
return Path(normalized_raw)
|
||||
return None
|
||||
candidate = _resolve_path(raw)
|
||||
base = _resolve_path(cwd)
|
||||
candidate = _safe_resolve(_expanduser_path(raw))
|
||||
base = _safe_resolve(_expanduser_path(cwd))
|
||||
if _is_blocked_workspace_path(candidate, raw) or _is_blocked_workspace_path(base, cwd):
|
||||
return None
|
||||
if candidate == base or _is_within(candidate, base):
|
||||
@@ -201,7 +402,7 @@ def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None:
|
||||
return None
|
||||
|
||||
|
||||
def _profile_default_workspace() -> str:
|
||||
def _profile_default_workspace(profile: str | Path | None = None) -> str:
|
||||
"""Read the profile's default workspace from its config.yaml.
|
||||
|
||||
Checks keys in priority order:
|
||||
@@ -217,8 +418,9 @@ def _profile_default_workspace() -> str:
|
||||
Falls back to the live DEFAULT_WORKSPACE from api.config.
|
||||
"""
|
||||
try:
|
||||
from api.config import get_config
|
||||
cfg = get_config()
|
||||
from api.config import get_config_for_profile_home
|
||||
profile_home = _resolve_profile_home_param(profile)
|
||||
cfg = get_config_for_profile_home(profile_home)
|
||||
terminal_cfg = cfg.get('terminal', {})
|
||||
remote_terminal = _is_remote_terminal_backend(terminal_cfg)
|
||||
# Explicit webui workspace keys first
|
||||
@@ -227,7 +429,7 @@ def _profile_default_workspace() -> str:
|
||||
if ws:
|
||||
if remote_terminal:
|
||||
return str(ws).strip()
|
||||
p = _resolve_path(str(ws))
|
||||
p = _resolve_path(str(ws), profile=profile)
|
||||
if remote_terminal or p.is_dir():
|
||||
return str(p)
|
||||
# Fall through to terminal.cwd — the agent's configured working directory
|
||||
@@ -236,7 +438,7 @@ def _profile_default_workspace() -> str:
|
||||
if cwd and str(cwd) not in ('.', ''):
|
||||
if remote_terminal:
|
||||
return str(cwd).strip()
|
||||
p = _resolve_path(str(cwd))
|
||||
p = _resolve_path(str(cwd), profile=profile)
|
||||
if remote_terminal or p.is_dir():
|
||||
return str(p)
|
||||
except (ImportError, Exception):
|
||||
@@ -244,14 +446,14 @@ def _profile_default_workspace() -> str:
|
||||
try:
|
||||
from api.config import DEFAULT_WORKSPACE as _LIVE_DEFAULT_WORKSPACE
|
||||
|
||||
return str(_resolve_path(_LIVE_DEFAULT_WORKSPACE))
|
||||
return str(_resolve_path(_LIVE_DEFAULT_WORKSPACE, profile=profile))
|
||||
except Exception:
|
||||
return str(_resolve_path(_BOOT_DEFAULT_WORKSPACE))
|
||||
return str(_resolve_path(_BOOT_DEFAULT_WORKSPACE, profile=profile))
|
||||
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _clean_workspace_list(workspaces: list) -> list:
|
||||
def _clean_workspace_list(workspaces: list, profile: str | Path | None = None) -> list:
|
||||
"""Sanitize a workspace list:
|
||||
- Preserve target-side remote terminal workspace paths (SSH/Docker) without
|
||||
resolving them against the local WebUI host filesystem.
|
||||
@@ -270,7 +472,7 @@ def _clean_workspace_list(workspaces: list) -> list:
|
||||
name = w.get('name', '')
|
||||
if not path:
|
||||
continue
|
||||
remote_cand = _remote_terminal_workspace_candidate(path)
|
||||
remote_cand = _remote_terminal_workspace_candidate(path, profile=profile)
|
||||
if remote_cand is not None:
|
||||
p = remote_cand
|
||||
else:
|
||||
@@ -283,7 +485,14 @@ def _clean_workspace_list(workspaces: list) -> list:
|
||||
# p is under ~/.hermes/profiles/ — only skip if it's under a DIFFERENT profile
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
own_profile_dir = get_active_hermes_home().resolve()
|
||||
if profile is not None:
|
||||
# Explicit profile wins: the list belongs to that profile,
|
||||
# so "own" is defined by the profile parameter, never by the
|
||||
# ambient home (loading profile A's list under ambient B must
|
||||
# not silently drop A's own workspaces).
|
||||
own_profile_dir = _resolve_profile_home_param(profile).resolve()
|
||||
else:
|
||||
own_profile_dir = get_active_hermes_home().resolve()
|
||||
p.relative_to(own_profile_dir)
|
||||
# p is under our own profile dir — keep it
|
||||
except (ValueError, Exception):
|
||||
@@ -350,12 +559,12 @@ def _migrate_global_workspaces() -> list:
|
||||
return []
|
||||
|
||||
|
||||
def load_workspaces() -> list:
|
||||
ws_file = _workspaces_file()
|
||||
if ws_file.exists():
|
||||
def load_workspaces(profile: str | Path | None = None) -> list:
|
||||
ws_file = _workspaces_file_for_profile(profile)
|
||||
if ws_file is not None and ws_file.exists():
|
||||
try:
|
||||
raw = json.loads(ws_file.read_text(encoding='utf-8'))
|
||||
cleaned = _clean_workspace_list(raw)
|
||||
cleaned = _clean_workspace_list(raw, profile=profile)
|
||||
if len(cleaned) != len(raw):
|
||||
# Persist the cleaned version so stale entries don't keep reappearing
|
||||
try:
|
||||
@@ -364,7 +573,7 @@ def load_workspaces() -> list:
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to persist cleaned workspace list")
|
||||
return cleaned or [{'path': _profile_default_workspace(), 'name': 'Home'}]
|
||||
return cleaned or [{'path': _profile_default_workspace(profile=profile), 'name': 'Home'}]
|
||||
except Exception:
|
||||
logger.debug("Failed to load workspaces from %s", ws_file)
|
||||
# No profile-local file yet.
|
||||
@@ -372,7 +581,11 @@ def load_workspaces() -> list:
|
||||
# For NAMED profiles: always start clean with just their own workspace.
|
||||
try:
|
||||
from api.profiles import get_active_profile_name
|
||||
is_default = get_active_profile_name() in ('default', None)
|
||||
if profile is not None:
|
||||
profile_home = _resolve_profile_home_param(profile)
|
||||
is_default = _is_default_profile_home(profile_home)
|
||||
else:
|
||||
is_default = get_active_profile_name() in ('default', None)
|
||||
except ImportError:
|
||||
is_default = True
|
||||
if is_default:
|
||||
@@ -380,16 +593,20 @@ def load_workspaces() -> list:
|
||||
if migrated:
|
||||
return migrated
|
||||
# Fresh start: single entry from the profile's configured workspace, labeled "Home"
|
||||
return [{'path': _profile_default_workspace(), 'name': 'Home'}]
|
||||
return [{'path': _profile_default_workspace(profile=profile), 'name': 'Home'}]
|
||||
|
||||
|
||||
def save_workspaces(workspaces: list) -> None:
|
||||
ws_file = _workspaces_file()
|
||||
def save_workspaces(workspaces: list, profile: str | Path | None = None) -> None:
|
||||
ws_file = _workspaces_file_for_profile(profile)
|
||||
if ws_file is None:
|
||||
# Fail-closed: an invalid profile name must not write any state file
|
||||
# (it would land in the default profile's directory via the old clamp).
|
||||
raise ValueError(f"cannot save workspaces for invalid profile {profile!r}")
|
||||
ws_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
ws_file.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
|
||||
def get_profile_default_workspace() -> str:
|
||||
def get_profile_default_workspace(profile: str | Path | None = None) -> str:
|
||||
"""Resolve the ACTIVE PROFILE's default workspace, never the global file.
|
||||
|
||||
Like get_last_workspace() but WITHOUT the global ``_GLOBAL_LW_FILE``
|
||||
@@ -404,32 +621,38 @@ def get_profile_default_workspace() -> str:
|
||||
Priority: profile-scoped ``last_workspace.txt`` -> profile ``config.yaml``
|
||||
``workspace``/``default_workspace`` -> ``terminal.cwd`` -> process default.
|
||||
"""
|
||||
remote_cwd = _remote_terminal_cwd()
|
||||
try:
|
||||
remote_cwd = _remote_terminal_cwd(profile=profile) if profile is not None else _remote_terminal_cwd()
|
||||
except TypeError:
|
||||
remote_cwd = _remote_terminal_cwd()
|
||||
|
||||
def _valid(raw: str) -> str | None:
|
||||
if not raw:
|
||||
return None
|
||||
if remote_cwd:
|
||||
if _remote_terminal_workspace_candidate(raw) is not None:
|
||||
if _remote_terminal_workspace_candidate(raw, profile=profile) is not None:
|
||||
return raw
|
||||
return None
|
||||
if Path(raw).is_dir():
|
||||
return raw
|
||||
return None
|
||||
|
||||
lw_file = _last_workspace_file()
|
||||
if lw_file.exists():
|
||||
lw_file = _last_workspace_file_for_profile(profile)
|
||||
if lw_file is not None and lw_file.exists():
|
||||
try:
|
||||
p = _valid(lw_file.read_text(encoding='utf-8').strip())
|
||||
if p:
|
||||
return p
|
||||
except Exception:
|
||||
logger.debug("Failed to read profile last workspace from %s", lw_file)
|
||||
return _profile_default_workspace()
|
||||
return _profile_default_workspace(profile=profile)
|
||||
|
||||
|
||||
def get_last_workspace() -> str:
|
||||
remote_cwd = _remote_terminal_cwd()
|
||||
def get_last_workspace(profile: str | Path | None = None) -> str:
|
||||
try:
|
||||
remote_cwd = _remote_terminal_cwd(profile=profile) if profile is not None else _remote_terminal_cwd()
|
||||
except TypeError:
|
||||
remote_cwd = _remote_terminal_cwd()
|
||||
|
||||
def valid_last_workspace(raw: str) -> str | None:
|
||||
if not raw:
|
||||
@@ -438,35 +661,58 @@ def get_last_workspace() -> str:
|
||||
# For remote/SSH profiles, last_workspace is target-side state. Do
|
||||
# not accept stale server-local paths merely because they exist on
|
||||
# the WebUI host; require the value to stay under terminal.cwd.
|
||||
if _remote_terminal_workspace_candidate(raw) is not None:
|
||||
if _remote_terminal_workspace_candidate(raw, profile=profile) is not None:
|
||||
return raw
|
||||
return None
|
||||
if Path(raw).is_dir():
|
||||
return raw
|
||||
return None
|
||||
|
||||
lw_file = _last_workspace_file()
|
||||
if lw_file.exists():
|
||||
lw_file = _last_workspace_file_for_profile(profile)
|
||||
if lw_file is not None and lw_file.exists():
|
||||
try:
|
||||
p = valid_last_workspace(lw_file.read_text(encoding='utf-8').strip())
|
||||
if p:
|
||||
return p
|
||||
except Exception:
|
||||
logger.debug("Failed to read last workspace from %s", lw_file)
|
||||
# Fallback: try global file
|
||||
if _GLOBAL_LW_FILE.exists():
|
||||
# Fallback: try global file — but ONLY for the root/default profile. A named
|
||||
# profile must never inherit another profile's last-workspace binding through
|
||||
# the legacy global state (#7168 re-gate round 3). Identity is canonical:
|
||||
# a symlink alias of the default home still counts as default, while a
|
||||
# malformed profile name fails validation and is denied the fallback
|
||||
# rather than being clamped onto the global file (#7168 re-gate round 4).
|
||||
_global_fallback_allowed = True # ambient / no explicit profile: historical behavior
|
||||
if profile is not None:
|
||||
if str(profile).strip() == 'default':
|
||||
_global_fallback_allowed = True
|
||||
else:
|
||||
try:
|
||||
_global_fallback_allowed = _is_default_profile_home(
|
||||
_resolve_profile_home_param(profile)
|
||||
)
|
||||
except Exception:
|
||||
# Conservative default: an unresolvable explicit profile is NOT the
|
||||
# root profile — deny the global fallback rather than leak.
|
||||
_global_fallback_allowed = False
|
||||
if _global_fallback_allowed and _GLOBAL_LW_FILE.exists():
|
||||
try:
|
||||
p = valid_last_workspace(_GLOBAL_LW_FILE.read_text(encoding='utf-8').strip())
|
||||
if p:
|
||||
return p
|
||||
except Exception:
|
||||
logger.debug("Failed to read global last workspace")
|
||||
return _profile_default_workspace()
|
||||
return _profile_default_workspace(profile=profile)
|
||||
|
||||
|
||||
def set_last_workspace(path: str) -> None:
|
||||
def set_last_workspace(path: str, profile: str | Path | None = None) -> None:
|
||||
try:
|
||||
lw_file = _last_workspace_file()
|
||||
lw_file = _last_workspace_file_for_profile(profile)
|
||||
if lw_file is None:
|
||||
# Fail-closed: an invalid profile name must not write the default
|
||||
# profile's last_workspace.txt (#7168 re-gate round 4).
|
||||
logger.debug("Refusing to set last workspace for invalid profile %r", profile)
|
||||
return
|
||||
lw_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
lw_file.write_text(str(path), encoding='utf-8')
|
||||
except Exception:
|
||||
@@ -683,7 +929,15 @@ def _is_within(path: Path, root: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _trusted_workspace_roots() -> list[Path]:
|
||||
def _trusted_workspace_roots(profile: str | Path | None = None) -> list[Path]:
|
||||
"""Return the host directories workspace suggestions may traverse.
|
||||
|
||||
Saved-workspace roots follow the same trust rule as
|
||||
:func:`resolve_trusted_workspace`: with an explicit *profile*, only
|
||||
workspaces saved under THAT profile widen the boundary (plus the ambient
|
||||
home / boot-default carve-outs); ``None`` keeps the historical ambient /
|
||||
global saved-list behaviour.
|
||||
"""
|
||||
roots: list[Path] = []
|
||||
|
||||
def add(candidate: str | Path | None) -> None:
|
||||
@@ -702,24 +956,26 @@ def _trusted_workspace_roots() -> list[Path]:
|
||||
|
||||
add(_home_path())
|
||||
add(_BOOT_DEFAULT_WORKSPACE)
|
||||
for w in load_workspaces():
|
||||
for w in load_workspaces(profile=profile):
|
||||
add(w.get("path"))
|
||||
roots.sort(key=lambda p: len(str(p)))
|
||||
return roots
|
||||
|
||||
|
||||
def list_workspace_suggestions(prefix: str = "", limit: int = 12) -> list[str]:
|
||||
def list_workspace_suggestions(
|
||||
prefix: str = "", limit: int = 12, profile: str | Path | None = None
|
||||
) -> list[str]:
|
||||
"""Return workspace path suggestions under trusted roots only.
|
||||
|
||||
Suggestions are limited to directories under one of:
|
||||
- Path.home()
|
||||
- the boot default workspace
|
||||
- already-saved workspace roots
|
||||
- already-saved workspace roots (scoped to *profile* when given)
|
||||
|
||||
Arbitrary system prefixes return an empty list rather than an error so the
|
||||
UI can safely autocomplete while the user types.
|
||||
"""
|
||||
roots = _trusted_workspace_roots()
|
||||
roots = _trusted_workspace_roots(profile=profile)
|
||||
if not roots:
|
||||
return []
|
||||
|
||||
@@ -815,7 +1071,7 @@ def list_workspace_suggestions(prefix: str = "", limit: int = 12) -> list[str]:
|
||||
return suggestions[:limit]
|
||||
|
||||
|
||||
def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
|
||||
def resolve_trusted_workspace(path: str | Path | None = None, profile: str | Path | None = None) -> Path:
|
||||
"""Resolve and validate a workspace path.
|
||||
|
||||
A path is trusted if it satisfies at least one of:
|
||||
@@ -837,12 +1093,12 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
|
||||
trusted (it was validated at server startup).
|
||||
"""
|
||||
if path in (None, ""):
|
||||
return _resolve_path(_BOOT_DEFAULT_WORKSPACE)
|
||||
return _resolve_path(_BOOT_DEFAULT_WORKSPACE, profile) if profile is not None else _resolve_path(_BOOT_DEFAULT_WORKSPACE)
|
||||
|
||||
candidate = _resolve_path(path)
|
||||
candidate = _resolve_path(path, profile) if profile is not None else _resolve_path(path)
|
||||
|
||||
access_error = _workspace_access_error(candidate)
|
||||
remote_candidate = _remote_terminal_workspace_candidate(path)
|
||||
remote_candidate = _remote_terminal_workspace_candidate(path, profile=profile)
|
||||
if access_error:
|
||||
# For remote terminal profiles, workspace paths belong to the target
|
||||
# machine. Allow paths under terminal.cwd so session switching can
|
||||
@@ -869,6 +1125,11 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
|
||||
|
||||
# (B) Trusted if already in the saved workspace list — covers non-home installs
|
||||
try:
|
||||
saved = load_workspaces(profile=profile)
|
||||
saved_paths = {_resolve_path(w["path"], profile) for w in saved if w.get("path")}
|
||||
if candidate in saved_paths:
|
||||
return candidate
|
||||
except TypeError:
|
||||
saved = load_workspaces()
|
||||
saved_paths = {_resolve_path(w["path"]) for w in saved if w.get("path")}
|
||||
if candidate in saved_paths:
|
||||
@@ -898,15 +1159,22 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
|
||||
def resolve_implicit_workspace_with_recovery(
|
||||
candidate: str | Path | None,
|
||||
fallback: str | Path | None | Callable[[], str | Path | None],
|
||||
profile: str | Path | None = None,
|
||||
) -> tuple[Path, bool]:
|
||||
"""Resolve an implicit workspace, recovering only a genuinely missing path.
|
||||
|
||||
The fallback still passes through :func:`resolve_trusted_workspace`. Existing
|
||||
but untrusted, inaccessible, or non-directory candidates are not recovery
|
||||
cases: their original validation error is preserved so fallback cannot widen
|
||||
the workspace trust boundary.
|
||||
the workspace trust boundary. When *profile* is given, both the trust
|
||||
resolution and the recovery fallback are scoped to that profile.
|
||||
"""
|
||||
try:
|
||||
if profile is not None:
|
||||
try:
|
||||
return resolve_trusted_workspace(candidate, profile=profile), False
|
||||
except TypeError:
|
||||
pass
|
||||
return resolve_trusted_workspace(candidate), False
|
||||
except ValueError as original_error:
|
||||
if candidate in (None, ""):
|
||||
@@ -917,19 +1185,49 @@ def resolve_implicit_workspace_with_recovery(
|
||||
# never prove target-side deletion. Config-read uncertainty also fails
|
||||
# closed by preserving the original validation error.
|
||||
try:
|
||||
from api.config import get_config
|
||||
from api.config import get_config, get_config_for_profile_home
|
||||
|
||||
terminal_cfg = get_config().get("terminal", {})
|
||||
if profile is not None:
|
||||
# Classify the backend from THIS profile's own config, never the
|
||||
# ambient one: a remote profile without terminal.cwd must still be
|
||||
# recognized as remote when loaded under a different ambient home.
|
||||
terminal_cfg = get_config_for_profile_home(
|
||||
_resolve_profile_home_param(profile)
|
||||
).get("terminal", {})
|
||||
else:
|
||||
terminal_cfg = get_config().get("terminal", {})
|
||||
except Exception:
|
||||
logger.debug("Failed to classify terminal backend for workspace recovery", exc_info=True)
|
||||
raise original_error from None
|
||||
if _is_remote_terminal_backend(terminal_cfg):
|
||||
raise original_error from None
|
||||
try:
|
||||
local_candidate = _resolve_path(candidate)
|
||||
local_candidate = (
|
||||
_resolve_path(candidate, profile=profile)
|
||||
if profile is not None
|
||||
else _resolve_path(candidate)
|
||||
)
|
||||
local_candidate.stat()
|
||||
except FileNotFoundError:
|
||||
fallback_value = fallback() if callable(fallback) else fallback
|
||||
def _profile_bound_fallback():
|
||||
if profile is not None and callable(fallback):
|
||||
# Profile-bound getter FIRST (#7168 re-gate round 3): the
|
||||
# production call sites pass profile-aware getters such as
|
||||
# get_last_workspace, so binding the explicit profile must
|
||||
# take precedence over any zero-argument compatibility call,
|
||||
# which would read ambient/global state.
|
||||
try:
|
||||
return fallback(profile)
|
||||
except TypeError:
|
||||
pass
|
||||
return fallback() if callable(fallback) else fallback
|
||||
|
||||
fallback_value = _profile_bound_fallback()
|
||||
if profile is not None:
|
||||
try:
|
||||
return resolve_trusted_workspace(fallback_value, profile=profile), True
|
||||
except TypeError:
|
||||
pass
|
||||
return resolve_trusted_workspace(fallback_value), True
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
raise original_error from None
|
||||
@@ -957,7 +1255,7 @@ def _strip_surrounding_quotes(path: str) -> str:
|
||||
return s
|
||||
|
||||
|
||||
def validate_workspace_to_add(path: str) -> Path:
|
||||
def validate_workspace_to_add(path: str, profile: str | Path | None = None) -> Path:
|
||||
"""Validate a path for *adding* to the workspace list (less restrictive than resolve_trusted_workspace).
|
||||
|
||||
When a user explicitly adds a new workspace path, we trust their intent — they
|
||||
@@ -972,10 +1270,10 @@ def validate_workspace_to_add(path: str) -> Path:
|
||||
and users routinely paste those into the Add Space input.
|
||||
"""
|
||||
path = _strip_surrounding_quotes(path)
|
||||
candidate = _resolve_path(path)
|
||||
candidate = _resolve_path(path, profile) if profile is not None else _resolve_path(path)
|
||||
|
||||
access_error = _workspace_access_error(candidate)
|
||||
remote_candidate = _remote_terminal_workspace_candidate(path)
|
||||
remote_candidate = _remote_terminal_workspace_candidate(path, profile=profile)
|
||||
if access_error:
|
||||
# Remote terminal profiles validate workspace existence on the target
|
||||
# machine, not on the WebUI server. Permit target-side paths under
|
||||
|
||||
@@ -23,6 +23,10 @@ contributor guidance; it does not change runtime behavior or CI gates.
|
||||
|
||||
## Runtime, durability, and state contracts
|
||||
|
||||
- [`docs/remote-workspaces.md`](remote-workspaces.md):
|
||||
architecture contract for remote terminal workspaces (SSH/Docker), target-side
|
||||
POSIX path preservation against macOS synthetic firmlink expansion, and
|
||||
per-profile isolation boundaries.
|
||||
- [`docs/rfcs/webui-run-state-consistency-contract.md`](rfcs/webui-run-state-consistency-contract.md):
|
||||
proposed consistency rules for current WebUI streaming, recovery, replay,
|
||||
model-context reconstruction, compression, UI scene/cache, and sidebar metadata
|
||||
@@ -78,6 +82,30 @@ contributor guidance; it does not change runtime behavior or CI gates.
|
||||
proof gates. Prefer the RFC's **Authoritative emitted events** table (live
|
||||
`/api/chat/stream` wire names) over the aspirational semantic taxonomy when
|
||||
writing clients against current source.
|
||||
- [`docs/architecture/models-cache-invalidation.md`](architecture/models-cache-invalidation.md):
|
||||
current contract for the `/api/models` catalog cache identity: the
|
||||
`config.yaml`, `auth.json`, and catalog source-fingerprint axes, the
|
||||
one-directional volatile-key deny-lists (auth rotation and Codex's
|
||||
`models_cache.json` refresh timestamps), the stat-identity fallbacks, and the
|
||||
schema/version stamps. Start here before changing model-catalog caching, the
|
||||
`/api/models` cache keys, or the Codex catalog dependency (#2443, #7540).
|
||||
- [`docs/architecture/live-models-allowlist.md`](architecture/live-models-allowlist.md):
|
||||
current contract for how `/api/models/live` filters a custom provider's
|
||||
upstream catalog: the four signals in evaluation order (discovered catalog
|
||||
defers to the live probe, explicit plural `models:` allowlist filters,
|
||||
singular `model:` never gates, no allowlist shows the full catalog), the
|
||||
serialized-list shapes `hermes config set` persists, the deliberate
|
||||
empty-allowlist-is-not-configured rule, and the probe-failure fallback.
|
||||
Start here before changing custom-provider model filtering or
|
||||
discovery-vs-allowlist semantics (#7165, #7404).
|
||||
- [`docs/architecture/profile-home-resolve-cache.md`](architecture/profile-home-resolve-cache.md):
|
||||
current contract for the call-scoped memoization around
|
||||
`_resolve_profile_home_param()` in `api/workspace.py`: what it caches, the
|
||||
one-hot-loop scope (`_load_cli_sessions_uncached`) versus every other call
|
||||
site getting fresh resolution, and the freshness guarantee that a symlink
|
||||
retarget or transient resolve fallback is always observed on the next call.
|
||||
Start here before widening this cache's scope or adding a similar
|
||||
call-scoped cache elsewhere (#7636).
|
||||
|
||||
When a change touches streaming, recovery, replay, compression, context
|
||||
reconstruction, cancellation, approval/clarify, session metadata, or run state,
|
||||
|
||||
@@ -146,6 +146,33 @@ When adding a control, consider where users will find it on both wide desktop an
|
||||
mobile. If a setting or quota/control surface does not fit in the composer, route
|
||||
it through the appropriate Control Center panel instead of squeezing the footer.
|
||||
|
||||
### Composer sizing
|
||||
|
||||
The composer grows with its content up to a 200px cap. Where the browser supports
|
||||
`field-sizing: content` (the stylesheet sets it on `textarea#msg`, with
|
||||
`field-sizing: fixed` while the placeholder shows) CSS owns that; everywhere else
|
||||
the JavaScript fallback (`autoResize()` in `static/messages.js`) measures, and it
|
||||
runs on every keystroke - so treat it as a hot path and keep these invariants when
|
||||
touching it (regression coverage:
|
||||
`tests/test_long_session_composer_typing_latency.py`, which derives every
|
||||
dimension from `static/style.css`, and
|
||||
`tests/test_issue5514_composer_grow_scroll_pin.py`):
|
||||
|
||||
- A single-row append that already fits its box skips the height round trip. That
|
||||
round trip reads `scrollHeight`, which forces a synchronous layout of the whole
|
||||
document, so its cost grows with the rendered transcript - this is the
|
||||
long-session typing-lag class. Do not remove the skip.
|
||||
- The skip's ceiling is the textarea's natural ONE-ROW height (`line-height` +
|
||||
vertical padding + borders) or the CSS `min-height`, whichever is larger.
|
||||
Compare against that natural row, never against `min-height` alone: the natural
|
||||
row follows the appearance font size (44px at the 16px default, 48px at
|
||||
`data-font-size=large`, 51px at `xlarge`), while `min-height` stays 44px, so a
|
||||
min-height-only ceiling silently disables the skip for the larger sizes.
|
||||
- Everything else still fully remeasures: an oversized composer, a replacement, a
|
||||
shrink, a multi-line append, and session/draft restore.
|
||||
- Non-pixel computed values (a percentage, `calc()`, `auto`) fail closed to the
|
||||
full resize rather than enabling the skip from a bogus pixel parse.
|
||||
|
||||
## Responsive behavior
|
||||
|
||||
Mobile is not an afterthought. The repository documents a responsive layout with
|
||||
|
||||
@@ -119,6 +119,8 @@ HERMES_WEBUI_GATEWAY_USE_RUNS_API=true \
|
||||
|
||||
Use this when the connected gateway advertises approval support and you want tool approval cards to appear in WebUI. Without `HERMES_WEBUI_GATEWAY_USE_RUNS_API=true`, gateway chat stays on the legacy chat-completions transport and approval-capable commands can remain pending in the agent without a WebUI approval card.
|
||||
|
||||
On the runs API path the turn is executed by the Gateway, so restarting WebUI does not stop it. WebUI stores the Gateway `run_id` on the pending turn (and submits it with an `Idempotency-Key` so the Gateway keeps a durable run record). On startup, WebUI reattaches to every such run by polling `GET /v1/runs/{run_id}` until it settles, then writes the real final answer into the session instead of a "Response interrupted" marker. Stop still cancels a reattached run, and a pending approval is shown again. Token-by-token output from before the restart is not replayed; the reattached turn shows only the final answer. If the Gateway no longer knows the run (for example, it restarted too and the run was interrupted), the turn ends with an error message instead. The legacy chat-completions transport cannot reattach: its turn ends when the WebUI process that holds the HTTP stream exits.
|
||||
|
||||
When YOLO is enabled for a gateway-backed browser session, WebUI approves every
|
||||
approval already parked for that session: Runs API prompts are relayed by their
|
||||
exact `run_id` and mirror token, and local/no-run waiters are all released. It
|
||||
|
||||
@@ -41,10 +41,109 @@ helpers are genuinely shared code.
|
||||
| `docker_agent_source_volume` | Compose files and Docker docs expose `hermes-agent-src` and `/opt/hermes` to make the agent checkout visible to WebUI. | Remove the WebUI source mount only after startup install and runtime imports have migrated. This needs Docker/compose follow-up work, not a runtime behavior change in this audit PR. |
|
||||
| `startup_dependency_install` | `api/startup.py` discovers `HERMES_WEBUI_AGENT_DIR` or `$HERMES_HOME/hermes-agent`; `server.py` calls `auto_install_agent_deps()` after import verification fails; `docker_init.bash` installs from the staged agent source. | Replace source-tree pip installs with a packaged hermes-agent WebUI client plus an agent health/version capability contract. Keep `HERMES_WEBUI_AGENT_DIR` during migration as an override/debug path, but it should stop being required in normal multi-container startup. |
|
||||
| `runtime_auxiliary_model_metadata` | `api/streaming.py`, `api/routes.py`, `api/config.py`, and `api/providers.py` import `agent.auxiliary_client`, `agent.model_metadata`, `agent.models_dev`, `hermes_cli.models`, and `agent.account_usage`. | Existing provider/model WebUI endpoints can keep serving UI data where they already wrap agent helpers. Missing surfaces need hermes-agent endpoints or a client package for auxiliary task config, text auxiliary calls, context length, token estimate, provider catalog, and account usage. |
|
||||
| `runtime_session_state` | `api/streaming.py`, `api/goals.py`, and `api/state_sync.py` import `hermes_state.SessionDB` directly. `api/models.py` also opens the active profile's canonical `state.db` for scoped session deletion because the current canonical helper does not preserve branch/compression evidence ahead of inherited delegate metadata or expose retryable artifact-cleanup semantics. | Move cross-container state reads and writes, including destructive session deletion, behind hermes-agent session/state endpoints once the agent API provides equivalent lineage precedence, transaction, and retry-manifest guarantees. WebUI-only presentation state can remain local, but agent session storage should not be opened from the WebUI container. |
|
||||
| `runtime_session_state` | `api/streaming.py`, `api/goals.py`, and `api/state_sync.py` import `hermes_state.SessionDB` directly. `api/models.py` also reads the `messages` table directly (see [state.db message content encoding](#statedb-message-content-encoding) for the storage-format coupling that creates) and opens the active profile's canonical `state.db` for scoped session deletion because the current canonical helper does not preserve branch/compression evidence ahead of inherited delegate metadata or expose retryable artifact-cleanup semantics. | Move cross-container state reads and writes, including destructive session deletion, behind hermes-agent session/state endpoints once the agent API provides equivalent lineage precedence, transaction, and retry-manifest guarantees. WebUI-only presentation state can remain local, but agent session storage should not be opened from the WebUI container. |
|
||||
| `runtime_gateway_provider` | `api/streaming.py` and `api/routes.py` import `hermes_cli.runtime_provider`; adapter helpers such as `agent.anthropic_adapter` are also imported for gateway normalization. | Provider resolution, runtime routing, and gateway invocation should be hermes-agent API calls. WebUI can keep request validation and display formatting, but it should not import runtime provider internals from the agent checkout. |
|
||||
| `webui_local_or_client_package` | WebUI imports `hermes_cli.auth`, `hermes_cli.config`, `hermes_cli.plugins`, `hermes_cli.profiles`, `hermes_cli.goals`, `agent.skill_utils`, `agent.credential_pool`, and `hermes_constants`. | Pure schemas, constants, and parsing helpers can move into a small versioned client/shared package. Privileged data such as credential pools, auth status, profile mutation, plugin discovery, and goal persistence need hermes-agent endpoints. UI-only formatting can remain in WebUI. |
|
||||
|
||||
## state.db message content encoding
|
||||
|
||||
`api/models.py` reads the agent's `messages` table with its own SQL, so it also
|
||||
depends on how hermes-agent *encodes* that table, not only on its schema. This
|
||||
is a storage-format coupling and belongs with the `runtime_session_state`
|
||||
dependency class above.
|
||||
|
||||
`hermes_state` stores list/dict message content (multimodal parts) as a
|
||||
sentinel-prefixed JSON string, because sqlite3 binds only scalars:
|
||||
|
||||
```
|
||||
_CONTENT_JSON_PREFIX = "\x00json:" # hermes_state.py
|
||||
```
|
||||
|
||||
It provides `_decode_content()` to reverse this. Any WebUI read path that
|
||||
projects that column must apply an equivalent decode; a raw read hands the
|
||||
frontend an encoded string that no reader recognises, and an image part's
|
||||
base64 data URI then renders as literal transcript text.
|
||||
|
||||
### WebUI decoding contract
|
||||
|
||||
`_decode_state_db_content()` in `api/models.py` is the single decode point. It
|
||||
is deliberately narrower than the agent's own decoder, because the WebUI can
|
||||
only accept shapes the rest of its pipeline already renders:
|
||||
|
||||
| Input | Result | Why |
|
||||
| --- | --- | --- |
|
||||
| Sentinel + list with non-whitespace text and only valid image parts | decoded `list` | `msgContent()` joins the text parts, so the row renders its text |
|
||||
| Sentinel + image-only list, or text that is empty/whitespace | unchanged string | `msgContent()` discards image parts, so `_messageIsRenderable()` would hide the row with no error |
|
||||
| Sentinel + list containing a malformed image part | unchanged string | a part must carry a valid per-type payload, not just a matching `type` |
|
||||
| Sentinel + dict or scalar root | unchanged string | a dict reaches `_getCachedRender()`, and `_renderCacheKey()` calls `text.slice()` on it, blanking the turn |
|
||||
| Sentinel + `NaN`/`Infinity`/overflowed float | unchanged string | Python emits them, browser `JSON.parse()` rejects the whole `/api/session` payload |
|
||||
| Sentinel + unsupported part shapes | unchanged string | `input_text`, `output_text`, scalar and unknown parts are dropped by the JS readers, so decoding them would silently lose content that is visible today |
|
||||
| Anything without the sentinel | unchanged | non-sentinel content is not this contract's concern |
|
||||
|
||||
Supported parts are `{"type": "text", "text": <str>}` plus image parts whose
|
||||
payload validates for their type: `image_url` with a non-empty URL (string or
|
||||
`{"url": ...}`), `input_image` with a URL or `file_id`, and `image` with a
|
||||
`base64` source carrying `data` and `media_type` or a `url` source. At least one
|
||||
text part must contain non-whitespace text.
|
||||
|
||||
**Image parts do not render from this projection.** The shared JS readers drop
|
||||
them, and the state.db projection supplies no `attachments`. Decoding a
|
||||
text-and-image row shows its text and keeps the base64 payload out of the DOM;
|
||||
it does not display the image. Rendering images from state.db rows would need a
|
||||
shared inline-image projection first, at which point image-only lists could be
|
||||
accepted too.
|
||||
|
||||
Widening the accepted schema requires teaching every shared content reader
|
||||
through one extractor first; until then unsupported shapes must keep falling
|
||||
back to the raw string.
|
||||
|
||||
### Consequences for identity and bounded reads
|
||||
|
||||
Decoding changes the runtime type of `content`, so every consumer that derives
|
||||
an identity from it must agree on one representation:
|
||||
|
||||
- Every key -- merge, dedup, content, visible and the fuzzy fallback -- derives
|
||||
content identity through `_content_identity_for_key()`. Non-list values key
|
||||
exactly as on master, `str(content or "")`. Non-empty lists get an
|
||||
**out-of-band** tuple identity, so no message body can compare equal to one:
|
||||
an in-band string marker would be forgeable by a scalar that contains it.
|
||||
Two rich turns sharing visible text and timestamp stay distinct when their
|
||||
images differ.
|
||||
- Fuzzy duplicate matching is text-only. Structured identities match by exact
|
||||
identity or not at all, so a rich row can never fuzzy-match a scalar.
|
||||
- The merge key cache never writes a key component back into message content.
|
||||
To avoid re-serialising large payloads for every key it instead memoises the
|
||||
canonical serialisation per content object, scoped to one
|
||||
`merge_session_messages_append_only()` call.
|
||||
- The multimodal mirror bridge pairs one rich image-bearing row with one scalar
|
||||
mirror only. `require_image_parts` and `require_scalar_mirror` are mutually
|
||||
exclusive so rich-to-rich pairing cannot occur.
|
||||
- Every read path that projects the `content` column applies the decoder, so
|
||||
keys derived on one path cannot disagree with keys derived on another. There
|
||||
are exactly three such call sites:
|
||||
|
||||
| Call site | Role |
|
||||
| --- | --- |
|
||||
| `_project_state_db_message()` | canonical row projection, shared by the transcript read and the regeneration tail |
|
||||
| `get_state_db_session_message_keys_before_timestamp()` | bounded prefix keys |
|
||||
| `get_state_db_regeneration_tail_snapshot()` | regeneration prefix keys |
|
||||
|
||||
If prefix keys stayed encoded while the projected tail was decoded, the
|
||||
prefix/tail collision proof could miss a genuine repeated recovered turn and
|
||||
`_bounded_tail_snapshot_if_safe` would reject the bounded path, reading the
|
||||
entire transcript during regeneration.
|
||||
|
||||
Two nearby paths deliberately need no decode.
|
||||
`get_state_db_session_message_prefix_summary()` projects only timestamp
|
||||
counts and never selects `content`. State-db sidecar reconstruction
|
||||
(`_sync_sidecar_from_state_db_if_newer()`) sources its rows through
|
||||
`get_state_db_session_messages()`, so it inherits the canonical decoded
|
||||
projection rather than reading the column itself.
|
||||
|
||||
When session state moves behind hermes-agent endpoints, this decode should move
|
||||
with it: the agent should return structured content over the API and the WebUI
|
||||
should stop depending on the sentinel format at all.
|
||||
|
||||
## Replacement contract
|
||||
|
||||
### Existing endpoint candidates
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# Live model picker allowlist contract (`/api/models/live`)
|
||||
|
||||
This document records the runtime contract for how `/api/models/live` decides
|
||||
which models a `custom` / `custom:*` provider contributes to the chat model
|
||||
picker. It describes shipped behavior and changes no runtime behavior. It was
|
||||
added because the #7165 review flagged the contract as undocumented: the
|
||||
filtering rules live in `_handle_live_models` (`api/routes.py`) and had only
|
||||
code comments backing them.
|
||||
|
||||
Start here before changing how the live catalog of a custom provider is
|
||||
filtered, how discovery snapshots are distinguished from allowlists, or what
|
||||
the endpoint returns when the upstream probe fails.
|
||||
|
||||
## Why the endpoint filters at all
|
||||
|
||||
OpenAI-compatible gateways (New-API-style aggregators, LM Studio, Ollama
|
||||
proxies) expose their **entire** catalog from `/v1/models`, including
|
||||
image-generation, audio, TTS, and embedding models that are not chat models.
|
||||
The background enrichment path must not surface models the user never opted
|
||||
into — but it also must not hide models the gateway gained since config was
|
||||
last edited. The contract below is how those two goals are reconciled.
|
||||
|
||||
## The four signals, in evaluation order
|
||||
|
||||
For a custom provider the handler consults these signals:
|
||||
|
||||
1. **Discovered catalog → no allowlist (live probe wins).**
|
||||
When the provider entry carries `models_discovered: true` and discovery is
|
||||
allowed (`discover_models` is not an explicit opt-out), the `models:`
|
||||
mapping is a *snapshot Hermes persisted from a past discovery*, not a
|
||||
hand-curated list. It must not gate the live probe: a model the user pulls
|
||||
into LM Studio / Ollama after discovery has to appear in the picker.
|
||||
Detected by the shared predicate
|
||||
`api.config._provider_models_are_discovered_catalog()` — the same
|
||||
predicate the `/api/models` catalog path uses, so both endpoints agree.
|
||||
With this signal, no allowlist is derived and the full live catalog is
|
||||
returned.
|
||||
|
||||
2. **Explicit plural `models:` allowlist → filter.**
|
||||
A non-empty plural `models:` value on an entry that is *not* a discovered
|
||||
catalog expresses "show exactly these models". The live catalog is
|
||||
intersected with it, then any allowlisted model missing from the live
|
||||
response is appended (so an allowlisted-but-offline model stays
|
||||
selectable). This is the #7165 filter. Only **list** and **string**
|
||||
(serialized) shapes are plain allowlists — see "Dict-shaped `models:`"
|
||||
below.
|
||||
|
||||
3. **Singular `model:` → never gates.**
|
||||
The singular field is sticky/default metadata (the model to preselect),
|
||||
not an allowlist. A provider configured only with `model: assistant` keeps
|
||||
the full unfiltered live catalog; gating on it would collapse discovery to
|
||||
a single model.
|
||||
|
||||
4. **No allowlist → full live catalog.**
|
||||
Preserves the pre-#7165 discovery behavior for setups that rely on it.
|
||||
|
||||
### Serialized shapes
|
||||
|
||||
`hermes config set` and the JSON-mode editor persist lists as quoted
|
||||
JSON-array strings (`'["chat-a","chat-b"]'`) or Python literals
|
||||
(`"['chat-a']"`). The plural value is decoded through the shared
|
||||
`_parse_config_string_list()` before id extraction — but only when the value
|
||||
is a `str`; native `dict` / `list` values are walked directly so dict *entries*
|
||||
keep their `id|model|name` metadata (the decoder stringifies members). A
|
||||
dict-only parser drops serialized allowlists to "no allowlist" and floods the
|
||||
picker — the same bug class as `skills.disabled` (#7120 / #7134).
|
||||
|
||||
### Dict-shaped `models:`
|
||||
|
||||
A dict-shaped `models:` mapping (e.g. `{chat-a: {context_length: 128000}}`)
|
||||
is *per-model metadata* written by the Hermes Agent setup flow —
|
||||
`hermes_cli/model_switch.py::_save_custom_provider` and the setup wizard —
|
||||
**not** a catalog narrow. Treating its keys as an allowlist would collapse the
|
||||
live picker to the single saved default (keyless Ollama) while the CLI
|
||||
live-probe shows the full catalog. So a dict is **never** a plain allowlist:
|
||||
|
||||
- without an explicit `discover_models: false`, a dict-shaped `models:`
|
||||
contributes **no allowlist** and the full live catalog is returned;
|
||||
- with `discover_models: false` (bool `False` or the string forms
|
||||
`"false"`/`"no"`/`"0"`, case-insensitive — mirroring
|
||||
`model_switch_providers.py:557`), the dict **keys** are honored as a pinned
|
||||
allowlist.
|
||||
|
||||
This complements the discovered-catalog rule: `models_discovered: true`
|
||||
without the opt-out is already ignored (signal 1). The dict guard covers the
|
||||
Agent-setup shape that is *not* flagged discovered.
|
||||
|
||||
If someone later "fixes" the dict shape back into an always-allowlist, the
|
||||
regression test `test_dict_models_without_discover_false_does_not_gate` fails
|
||||
and forces the trade-off to be re-argued.
|
||||
|
||||
### Empty allowlist semantics
|
||||
|
||||
`models: []`, `models: "[]"`, an empty mapping, or a blank scalar are
|
||||
deliberately treated as **not configured**, never as "allow nothing". The
|
||||
filter runs only on a non-empty allowlist because gating on declared-ness
|
||||
would emit an empty picker and make the provider unselectable — a harder
|
||||
failure than showing a few extra models, and unrecoverable from the UI
|
||||
(`custom_providers` has no WebUI write path; it is hand-edited in
|
||||
config.yaml). There is no deny-all use case: a provider the user wants hidden
|
||||
is removed from `custom_providers` outright.
|
||||
|
||||
### Probe failure / empty probe
|
||||
|
||||
When the upstream probe fails or returns no models, the endpoint falls back
|
||||
to `allowlist or config ids` — the non-empty plural allowlist first, so a
|
||||
singular `model:` outside the allowlist cannot leak in precisely when the
|
||||
probe is unavailable; with no allowlist, the configured ids (singular +
|
||||
plural) are served. A discovered catalog therefore still serves its saved
|
||||
models on a transient probe failure instead of emptying the picker.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_issue3718_live_models_custom_probe.py` — real-handler
|
||||
(`_handle_live_models`) coverage of every signal above: plural list /
|
||||
JSON-array-string / Python-literal / list-of-dicts filtering, dict-shaped
|
||||
`models:` (not gated) and dict + `discover_models: false` (pinned),
|
||||
singular passthrough, malformed safety, discovered-catalog passthrough,
|
||||
`discover_models: false` pinning, and both fallback branches.
|
||||
- `tests/test_issue7404_models_discovered_not_allowlist.py` — the
|
||||
`/api/models` side of the discovered-catalog contract.
|
||||
- `tests/test_byok_model_dropdown.py` — picker rendering, profile-scoped
|
||||
caching, and current-session model preservation around this endpoint.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Models catalog cache invalidation contract (`/api/models`)
|
||||
|
||||
This document records the current identity and invalidation contract for the
|
||||
`/api/models` catalog cache in `api/config.py`. It describes shipped behavior and
|
||||
changes no runtime behavior. It was added after #7556 shipped in `exp-v0.52.303`,
|
||||
which the #7556 review flagged as an undocumented runtime contract.
|
||||
|
||||
## What is cached
|
||||
|
||||
- **In memory:** `_available_models_cache` plus `_available_models_cache_ts`,
|
||||
with `_AVAILABLE_MODELS_CACHE_TTL` set to 24 hours.
|
||||
- **On disk:** one `models_cache.json` per profile under the WebUI state
|
||||
directory (`_get_models_cache_path()`), stamped with `_schema_version` and
|
||||
`_webui_version`.
|
||||
- **Cold path:** `get_available_models(prefer_cache=...)`; the `prefer_cache`
|
||||
branch never starts a live provider rebuild.
|
||||
- **Hot path:** `_endpoint_advertised_model_ids()` reads only the published
|
||||
in-memory snapshot through the lock-free `_models_cache_provenance` tuple and
|
||||
validates it against the current source fingerprint before trusting it.
|
||||
`_sync_models_cache_provenance()` must run at every site that publishes or
|
||||
invalidates the snapshot, so the tuple can never tear.
|
||||
|
||||
## The five source axes
|
||||
|
||||
`_models_cache_source_fingerprint()` is the single chokepoint. A cache is served
|
||||
only when every axis matches the value recorded in the cache — the disk reader
|
||||
compares it in `_is_loadable_disk_cache()`, and the hot path compares it against
|
||||
the fingerprint captured at publish time.
|
||||
|
||||
| Axis | Identity | Why it is fingerprinted this way |
|
||||
| --- | --- | --- |
|
||||
| `config_yaml` | stat identity: `mtime_ns` + size (`_models_cache_file_fingerprint`) | The file is rewritten only on deliberate user edits, and any edit can change the provider/model set, so the cheap conservative identity wins. |
|
||||
| `auth_json` | content hash with a volatile-key deny-list (`_auth_store_semantic_fingerprint`, `_AUTH_FINGERPRINT_VOLATILE_KEYS`) | The credential store is rewritten roughly every 14 minutes by credential-pool / OAuth refresh; none of those rotating fields feed `detected_providers` or the returned catalog, and stat identity made the 24h cache churn on every refresh (RCA `t_d127953d` / `t_16551f61`). |
|
||||
| `env` | `[key, HMAC-SHA256(signing key, value)]` per non-empty `.env` entry, parsed by `providers._load_env_file` (`_models_cache_env_fingerprint`); plaintext values are never recorded | Env keys decide `detected_providers` and values such as `LM_BASE_URL` decide which endpoint is probed, so both key and value changes invalidate. |
|
||||
| `plugins` | `[relpath, mtime_ns, size]` of every non-bytecode file of each model-provider plugin, discovered like `providers._scan_home_layer`, flat manifests parsed with PyYAML like the agent (`_models_cache_plugin_fingerprint`) | The loader execs plugin code that builds `fallback_models`, so any file edit must invalidate, even without a `version` bump. |
|
||||
| `catalog` | baked-in provider catalog sha256 (`_PROVIDER_MODELS` + `_PROVIDER_DISPLAY`) plus the Codex local catalog (`_codex_models_cache_fingerprint`, `_CODEX_CACHE_FINGERPRINT_VOLATILE_KEYS`) | A restart after a catalog change must not keep serving a persisted payload for up to 24h (#2443). Codex rewrites `~/.codex/models_cache.json` on its own timer, bumping `mtime_ns` and size while models, `etag`, and `client_version` stay identical, so the Codex axis hashes **content** with only the refresh timestamps (`fetched_at`, `updated_at`) removed (#7540, #7556). |
|
||||
|
||||
The over-budget stale fallback (`_load_stale_models_cache_from_disk`) tolerates a stale
|
||||
`_webui_version` but never a source-fingerprint mismatch: such a snapshot is a wrong catalog.
|
||||
|
||||
## Invariant: deny-lists are one-directional
|
||||
|
||||
- Both volatile-key sets are deny-lists, never allow-lists. They may remove only
|
||||
fields that provably do not gate the provider/model set.
|
||||
- Every other field — including fields that Codex or the auth store may add in
|
||||
the future — stays in the fingerprint
|
||||
(`test_unknown_codex_field_stays_in_fingerprint`).
|
||||
- Consequence: excluding a volatile key can only make the fingerprint **more
|
||||
stable**; it can never hide a genuine catalog or provider change. When in
|
||||
doubt, keep the key in.
|
||||
|
||||
## Invariant: fallbacks are never less safe than stat
|
||||
|
||||
- Missing file → recorded as missing, and the fingerprint stays stable
|
||||
(`test_missing_codex_cache_fingerprint_is_stable_and_marked`).
|
||||
- Unreadable, corrupt, or mid-write JSON → stat identity, marked
|
||||
`unparsed-fallback`.
|
||||
- Transform failure — including `RecursionError` on a pathologically deep tree →
|
||||
stat identity, marked `encode-fallback`
|
||||
(`test_deeply_nested_codex_cache_degrades_to_stat_fallback_without_crashing`).
|
||||
The fingerprint must never raise into `/api/models`; a real rewrite still
|
||||
changes the stat identity, so the fallback is strictly no less safe than the
|
||||
pre-#7556 behavior.
|
||||
|
||||
## Version stamps (independent of the fingerprint)
|
||||
|
||||
- `_schema_version` must equal `_MODELS_CACHE_SCHEMA_VERSION` (currently `3`).
|
||||
Bump it when the cached payload shape changes incompatibly.
|
||||
- `_webui_version` must equal the running version, which forces a rebuild after
|
||||
every release so picker-shape fixes appear immediately instead of after the
|
||||
TTL expires. When the runtime version cannot be resolved (early boot), that
|
||||
check is skipped rather than wedging the boot.
|
||||
|
||||
## Invalidation paths: memory vs. disk
|
||||
|
||||
| Path | In-memory snapshot | Disk snapshot |
|
||||
| --- | --- | --- |
|
||||
| `invalidate_models_cache()` (default `delete_disk=True`) | dropped | **deleted** |
|
||||
| `invalidate_models_cache(delete_disk=False)` (`POST /api/profile/switch`) | dropped | kept |
|
||||
| `invalidate_provider_models_cache(provider_id)` | dropped | **deleted** |
|
||||
| `_get_fresh_memory_models_cache()` on fingerprint mismatch / invalid shape | dropped | untouched |
|
||||
| config-reload branch in `get_available_models()` | dropped | deleted by `_refresh_config_cache()` only when the *same* `config.yaml` path was already loaded and changed; a first load or path change (per-client switch) keeps it |
|
||||
|
||||
The switch keeps the disk snapshot because it is keyed per profile and
|
||||
`_is_loadable_disk_cache()` rejects it unless every axis above matches, so any
|
||||
new catalog input must become an axis first. `delete_profile_api()` and
|
||||
`create_profile_api()` unlink `models_cache.<name>.json`, so a recreated profile
|
||||
never inherits the old catalog.
|
||||
|
||||
## Change protocol
|
||||
|
||||
1. Add or change a source axis in `_models_cache_source_fingerprint()` only —
|
||||
one chokepoint, so the disk reader and the published-snapshot reader always
|
||||
agree.
|
||||
2. When adding a volatile key, name the value it derives from and prove it
|
||||
inert: one test that fails if the key is dropped from the deny-list, and one
|
||||
that shows a genuine catalog change still invalidates the cache.
|
||||
3. Keep the fingerprint cheap, deterministic, and safe to run synchronously —
|
||||
it is recomputed on cache reads, including a per-turn hot path.
|
||||
4. Do not add a second, parallel cache-identity mechanism. The stat-based
|
||||
`_models_cache_file_fingerprint()` stays only as the conservative fallback for
|
||||
the axes above.
|
||||
5. This is a runtime contract: changes here update this document and are
|
||||
described in the PR body. Release-note wording belongs in the PR body, not in
|
||||
`CHANGELOG.md`, which release commits own.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_issue7540_codex_catalog_fingerprint.py` covers both invariant groups:
|
||||
timestamp-only churn keeps the fingerprint identical (and a session visit after a
|
||||
Codex refresh needs no live rebuild), while genuine changes — a new model, a
|
||||
visibility change, any catalog field, any unknown field — still invalidate.
|
||||
|
||||
`tests/test_profile_switch_models_disk_cache.py` covers the switch: the disk
|
||||
snapshot survives it and is served without a live rebuild, each source-axis
|
||||
change or a delete/recreate forces a fresh rebuild, and a same-path config
|
||||
edit still deletes the snapshot.
|
||||
|
||||
## References
|
||||
|
||||
Issues/PRs: #2443, #7540, #7556, #7558. RCAs: `t_d127953d`, `t_16551f61`.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Profile-home resolve call-scoped cache (`api/workspace.py`)
|
||||
|
||||
This document records the current scope, ownership, and freshness contract for
|
||||
the call-scoped memoization added around `_resolve_profile_home_param()` in
|
||||
`api/workspace.py`. It describes shipped behavior and changes no runtime
|
||||
behavior. It was added after PR #7636 review flagged this cache lifecycle as
|
||||
an undocumented runtime contract.
|
||||
|
||||
## What is cached
|
||||
|
||||
- **What:** the resolved `Path` returned by `_safe_resolve()` for a given
|
||||
pre-resolve profile-home `Path`, inside `_cached_safe_resolve_profile_home()`.
|
||||
- **Key:** the unresolved profile-home `Path` passed in.
|
||||
- **Store:** a plain `dict[Path, Path]` held by the
|
||||
`_PROFILE_HOME_RESOLVE_SCOPE` `contextvars.ContextVar`, which defaults to
|
||||
`None` (no active scope).
|
||||
|
||||
## Scope: one call, not the process lifetime
|
||||
|
||||
The cache is deliberately **call-scoped**, not process-lifetime. It exists only
|
||||
for the duration of one wrapped call to `_load_cli_sessions_uncached` — the
|
||||
CLI/cron sidebar session-list build that resolves the same profile argument
|
||||
once per `Session` row (hundreds of times per request in the common case,
|
||||
which is the N+1 this cache fixes). `profile_home_resolve_cache_scope()` sets
|
||||
the `ContextVar` to a fresh `{}` on entry and resets it to the previous value
|
||||
in a `finally` block on exit, so no cache entry survives past the single call
|
||||
it was created for. An earlier revision of this fix cached for the life of the
|
||||
process; that was reverted in review (see #7636) because `Path.resolve()` is a
|
||||
filesystem call, not a pure function of process-startup constants.
|
||||
|
||||
## Ownership: which call sites get caching
|
||||
|
||||
- `profile_home_resolve_cache_scope()` is applied, as a decorator, to exactly
|
||||
one call site today: `_load_cli_sessions_uncached` in `api/models.py`.
|
||||
- Every other caller of `_resolve_profile_home_param()` /
|
||||
`_cached_safe_resolve_profile_home()` — and every call to those functions
|
||||
from outside an active scope, including calls that happen before or after
|
||||
the one wrapped call — gets **no caching**: `_PROFILE_HOME_RESOLVE_SCOPE.get()`
|
||||
returns `None`, and `_cached_safe_resolve_profile_home()` falls straight
|
||||
through to a fresh `_safe_resolve()` call, exactly matching pre-PR behavior.
|
||||
- Nested/reentrant use of `profile_home_resolve_cache_scope()` restores the
|
||||
previous (outer) value on exit rather than clobbering it, so nesting is safe
|
||||
but still never leaks a cache entry past the scope that created it.
|
||||
|
||||
## Freshness guarantee
|
||||
|
||||
Because the cache is call-scoped and discarded in `finally` after each use, a
|
||||
profile-home symlink retarget or a transient `_safe_resolve()` fallback
|
||||
(`OSError`/`RuntimeError`/`ValueError`, which deliberately returns the
|
||||
unresolved input) is always observed fresh on the **next** call — the cache
|
||||
never outlives the call that populated it. The only staleness window is
|
||||
*within* a single call, i.e. across the hundreds of `Session` rows built by one
|
||||
`_load_cli_sessions_uncached` invocation — which is correct and intended, since
|
||||
nothing about a profile's home path should change mid-call.
|
||||
|
||||
## Change protocol
|
||||
|
||||
1. If a new hot loop needs this memoization, wrap it with
|
||||
`profile_home_resolve_cache_scope()` (as a decorator or a `with` block) at
|
||||
its own call boundary — do not widen the existing scope on
|
||||
`_load_cli_sessions_uncached` to also cover unrelated code.
|
||||
2. Do not turn this into a process-lifetime or otherwise longer-lived cache.
|
||||
The scoping is the fix for the correctness bugs described above, not an
|
||||
incidental detail.
|
||||
3. This is a runtime contract: changes here update this document and are
|
||||
described in the PR body.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_profile_home_resolve_caching.py` covers: repeated resolves within
|
||||
one scope hit the cache once
|
||||
(`test_resolve_profile_home_param_caches_within_one_scope`,
|
||||
`test_resolve_profile_home_param_caches_within_one_scope_for_profile_name`);
|
||||
two separate scopes never share state, so a symlink change between them is
|
||||
observed on the second scope
|
||||
(`test_cache_does_not_persist_across_separate_scopes`); calls outside any
|
||||
scope never cache at all (`test_no_caching_outside_any_scope`); the decorator
|
||||
is actually wired onto `_load_cli_sessions_uncached`
|
||||
(`test_scope_decorator_use_matches_load_cli_sessions_uncached_wiring`); and the
|
||||
N+1 fix itself, both that it dedupes resolves on a cold sidecar-cache miss
|
||||
(`test_load_cli_sessions_uncached_dedupes_profile_home_resolve_on_sidecar_cache_miss`)
|
||||
and that resolve calls scale linearly with the fix disabled
|
||||
(`test_load_cli_sessions_uncached_profile_home_resolve_scales_with_fix_disabled`).
|
||||
|
||||
## References
|
||||
|
||||
PR: #7636.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Sidebar idle / terminal handoff evidence
|
||||
|
||||
## Failure and scope
|
||||
|
||||
The independent `/api/sessions` response can report idle before the chat SSE's
|
||||
terminal frame reaches the page. Three sidebar paths were retiring chat state:
|
||||
idle reconciliation, INFLIGHT purging, and optimistic-row retirement. The idle
|
||||
path clears the Worklog and active stream ID; the subsequent `done` is rejected
|
||||
as stale, so no settled Anchor scene is persisted.
|
||||
|
||||
A real-HTTP barrier reproduces this on both published PR head
|
||||
`75c8ff8d2a991f22389e3e4263ee03fb8d8f67a0` and frozen upstream
|
||||
`b09fded7b8c98b549a49aaaece3721015337d307`. Applying only the `sessions.js`
|
||||
product fix to the frozen upstream also passes the identical oracle: this is a
|
||||
shared lifecycle defect, not evidence that artifact replay introduced it. The
|
||||
historical hosted run lacks this instrumentation, so its exact interleaving
|
||||
cannot be proved retrospectively; its empty-surface/no-persistence signature
|
||||
matches the controlled reproduction.
|
||||
|
||||
## Reproduction
|
||||
|
||||
```sh
|
||||
LIFECYCLE_SCENARIO=normal LIFECYCLE_TEST_BITE=settle-worklog-frame-proof \
|
||||
.venv/bin/python tests/browser_conversation_lifecycle.py
|
||||
./scripts/test.sh tests/test_sidebar_terminal_handoff.py -q
|
||||
```
|
||||
|
||||
The existing frame proof now uses a test-only server wrapper to hold the actual
|
||||
`done` HTTP write. The real worker finishes; the real browser receives an idle
|
||||
sidebar response through `renderSessionList()` while its native EventSource
|
||||
remains open. The test verifies pane, INFLIGHT and registry ownership, then
|
||||
releases `done` and retains all existing painted-frame, final-answer, semantic
|
||||
activity and hard-reload assertions. The fixture always releases/cleans up and
|
||||
has a bounded wait. Normal production server startup is unchanged.
|
||||
|
||||
No renderer, terminal payload or persistence response is fabricated. Runtime
|
||||
events come from the existing deterministic localhost Gateway fixture, not a
|
||||
live model/provider. All state and files are disposable. These are browser
|
||||
viewport checks, not physical-device acceptance.
|
||||
|
||||
## Before / after
|
||||
|
||||
Each screenshot is taken after the idle sidebar response, before the terminal
|
||||
HTTP frame is released. The exact published source fails at both widths. The
|
||||
patched source preserves three live activity rows and then passes completion
|
||||
and hard reload. `results.json` records the predicate results without machine
|
||||
paths or credentials.
|
||||
|
||||
| Viewport | Before | After |
|
||||
| --- | --- | --- |
|
||||
| 1440 x 900 | [Empty surface](before-1440.png) | [Owned handoff retained](after-1440.png) |
|
||||
| 390 x 844 | [Empty surface](before-390.png) | [Owned handoff retained](after-390.png) |
|
||||
|
||||
The fix only defers these sidebar cleanups for the current pane's exact OPEN
|
||||
chat transport. Missing, CONNECTING, CLOSED and mismatched sources still take
|
||||
the existing stale recovery path.
|
||||
|
||||
## Missing terminal frame: bounded recovery
|
||||
|
||||
The OPEN-only guard at `85f1c2d34b62b0ade85f51ed2cf8c1cf56f5ab9e` could wait
|
||||
forever when the terminal frame never arrived. The follow-up keeps the live
|
||||
handoff for 1.5 seconds, then probes the exact stream through
|
||||
`/api/chat/stream/status` with an eight-second timeout and no retries.
|
||||
Repeated sidebar hints cannot restart the deadline or create concurrent
|
||||
requests. `active:true` keeps the OPEN owner intact even when the persisted
|
||||
session fields already look idle; only explicit runtime inactivity permits the
|
||||
canonical session restore. A failed or malformed runtime probe uses the
|
||||
existing interrupted-stream path rather than claiming successful settlement.
|
||||
Normal terminal delivery, transport replacement/close, navigation, and a newer
|
||||
optimistic turn invalidate the old request. Already-running stream-end recovery
|
||||
remains the sole recovery owner.
|
||||
|
||||
```sh
|
||||
LIFECYCLE_SCENARIO=normal LIFECYCLE_TEST_BITE=sidebar-idle-missing-terminal \
|
||||
.venv/bin/python tests/browser_conversation_lifecycle.py
|
||||
./scripts/test.sh tests/test_sidebar_idle_stream_recovery.py -q
|
||||
```
|
||||
|
||||
The new browser row keeps the real terminal HTTP barrier closed until cleanup;
|
||||
the client must recover without receiving `done` or `stream_end`. Published
|
||||
`85f1c2d34b` fails the 11-second completion bound with `S.busy` still true.
|
||||
The patched implementation restores the final answer and passes the unchanged
|
||||
painted-frame, semantic activity, persistence and hard-reload checks before the
|
||||
barrier is released. The cloud workflow runs both delayed- and missing-terminal
|
||||
rows. No live provider or production state is used.
|
||||
|
||||
A production Gateway regression also blocks post-turn goal evaluation after
|
||||
the success transcript has already cleared `active_stream_id` and pending
|
||||
fields. During that interval the worker and exact entry in `STREAMS` remain
|
||||
live and no `done` event has been emitted, proving why persisted session flags
|
||||
cannot authorize sidebar settlement on their own.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,66 @@
|
||||
[
|
||||
{
|
||||
"mode": "before",
|
||||
"viewport": [
|
||||
1440,
|
||||
900
|
||||
],
|
||||
"exit": 1,
|
||||
"observation": {
|
||||
"serverIdle": true,
|
||||
"paneRetained": false,
|
||||
"inflightRetained": false,
|
||||
"sourceRetained": true,
|
||||
"registryRetained": true,
|
||||
"liveRows": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "before",
|
||||
"viewport": [
|
||||
390,
|
||||
844
|
||||
],
|
||||
"exit": 1,
|
||||
"observation": {
|
||||
"serverIdle": true,
|
||||
"paneRetained": false,
|
||||
"inflightRetained": false,
|
||||
"sourceRetained": true,
|
||||
"registryRetained": true,
|
||||
"liveRows": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "after",
|
||||
"viewport": [
|
||||
1440,
|
||||
900
|
||||
],
|
||||
"exit": 0,
|
||||
"observation": {
|
||||
"serverIdle": true,
|
||||
"paneRetained": true,
|
||||
"inflightRetained": true,
|
||||
"sourceRetained": true,
|
||||
"registryRetained": true,
|
||||
"liveRows": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"mode": "after",
|
||||
"viewport": [
|
||||
390,
|
||||
844
|
||||
],
|
||||
"exit": 0,
|
||||
"observation": {
|
||||
"serverIdle": true,
|
||||
"paneRetained": true,
|
||||
"inflightRetained": true,
|
||||
"sourceRetained": true,
|
||||
"registryRetained": true,
|
||||
"liveRows": 3
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Remote Terminal Workspaces
|
||||
|
||||
Architecture contract and path-resolution semantics for remote terminal profiles (SSH, Docker) in Hermes WebUI.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
When a Hermes profile is configured with a remote terminal backend (e.g. `terminal.backend: "ssh"` or `"docker"`), its working directory (`terminal.cwd`) lives on the remote target host rather than the local WebUI host filesystem.
|
||||
|
||||
On hosts such as macOS, local path resolution via `Path.resolve()` or `os.path.realpath()` expands synthetic firmlinks (e.g. rewriting `/home/<user>` to `/System/Volumes/Data/home/<user>`). Because the target-side path does not exist on the local macOS server, unconstrained local resolution causes runtime validation failures and session corruption.
|
||||
|
||||
---
|
||||
|
||||
## 2. Path Resolution Contract
|
||||
|
||||
1. **Target-side POSIX Path Preservation:**
|
||||
- Any valid POSIX path at or beneath a remote profile's configured `terminal.cwd` is preserved verbatim as a `Path` object without calling host-local `Path.resolve()`.
|
||||
- Traversal escapes (`..`), null bytes (`\0`), and blocked system roots (`/etc`, `/usr`, `/var`, `/sys`, `/proc`, `/dev`) continue to be strictly rejected.
|
||||
|
||||
2. **Profile Isolation & Boundary Enforcement:**
|
||||
- Remote path recognition is **strictly scoped to the target/active profile**.
|
||||
- If an active profile has a local backend, target-side remote paths belonging to inactive named profiles are **not** treated as remote for the active local profile.
|
||||
- For local profiles, workspace addition (`/api/workspaces/add`) enforces host-local directory existence and permissions.
|
||||
- For remote profiles, workspace addition validates against the profile's remote `terminal.cwd` and skips host-local directory creation (`mkdir`).
|
||||
|
||||
3. **Session & Streaming Lifecycle:**
|
||||
- `Session.__init__` and `Session.load` normalize session workspace paths through `_resolve_path(..., profile=profile)`, preserving target-side paths for remote profiles and preventing corruption of `session.workspace` or `session.created_workspace`.
|
||||
- Streaming execution (`_run_agent_streaming`) and multimodal asset root resolution preserve the remote session workspace when updating runtime run state.
|
||||
@@ -470,6 +470,19 @@ Expected behavior:
|
||||
|
||||
- A session row's running indicator should reflect a real active run or a
|
||||
clearly restorable state, not stale persisted metadata alone.
|
||||
- An idle sidebar response does not prove that the browser has consumed the
|
||||
terminal chat frame. While the current pane still owns its exact OPEN chat
|
||||
transport, list reconciliation and cache purging must preserve that handoff.
|
||||
Missing, connecting, closed, or mismatched transports keep the normal stale
|
||||
state recovery path; a cached stream ID or busy flag is insufficient.
|
||||
OPEN is not proof that a terminal frame will arrive: an idle hint must also
|
||||
schedule bounded recovery without erasing the live scene. Before reading a
|
||||
settled session snapshot, that recovery must check the exact stream's runtime
|
||||
status. A runtime-active stream retains ownership even when persisted
|
||||
`active_stream_id` and pending fields are already clear; only explicit
|
||||
runtime inactivity permits canonical snapshot settlement. Probe failure is a
|
||||
degraded/interrupted state, not evidence of successful completion. A newer
|
||||
turn, replacement source, or received terminal event invalidates the request.
|
||||
- Background completion, cancellation, or failure should be represented without
|
||||
stealing the visible pane from the user.
|
||||
- Session switching should not erase pending live context, in-flight snapshots,
|
||||
|
||||
@@ -59,6 +59,41 @@ proposes. It is routed in `api/routes.py` and implemented by
|
||||
is a global invalidation signal, not a per-session lifecycle stream. The proposed
|
||||
`GET /api/sessions/{session_id}/events` is per-session and path-distinct.
|
||||
|
||||
### Hidden-tab observation and recovery (implemented client behavior)
|
||||
|
||||
The browser closes its persistent per-session SSE while hidden and uses
|
||||
`_startHiddenActiveStreamPoll()` in `static/messages.js` to poll
|
||||
`GET /api/session/status?session_id=...` immediately and then every six seconds,
|
||||
subject to browser timer throttling. An active stream can be attached through
|
||||
the existing replay path; successful attachment stops the poll.
|
||||
|
||||
HTTP `404` is ambiguous: older profile-visibility guards and the legacy
|
||||
unknown-profile path can return it for a live session. Current master returns
|
||||
`409 session_profile_mismatch` for a known foreign profile; that response
|
||||
remains retryable when another tab changes the browser-wide profile cookie.
|
||||
A single `404` therefore keeps polling and retains the hidden-resume owner.
|
||||
After three consecutive `404` responses, the poll pauses to bound repeated
|
||||
missing-session requests, but retains that owner so returning to the visible
|
||||
tab can reopen SSE. Any other response or network error resets this budget;
|
||||
a newly started poll also starts with a fresh budget.
|
||||
|
||||
HTTP `410` is terminal: it stops the interval and clears the matching
|
||||
hidden-resume session ID. Returning to the visible tab then does not reopen
|
||||
SSE through that owner. Responses and queued ticks belong to one poll timer,
|
||||
not merely a session ID: they cannot stop or attach a replacement poll, even
|
||||
when the replacement observes the same session. The budget counts responses
|
||||
received by the current poll. Cleanup affects browser observation state only;
|
||||
it does not delete a session or cancel an agent run.
|
||||
|
||||
Successful idle responses with no `active_stream_id`, network failures, and
|
||||
other non-success HTTP responses (including `401`, `403`, `429`, and `5xx`)
|
||||
remain retryable. Visibility return normally restores per-session SSE when a
|
||||
resume owner still exists, including after repeated `404` responses. Only a
|
||||
terminal `410` removes that automatic recovery path. A profile restored before
|
||||
the three-miss limit can recover through the next hidden poll; after the limit,
|
||||
recovery waits for visibility return or explicit session loading. Behavior coverage lives in
|
||||
`tests/test_hidden_tab_server_initiated_turn.py`.
|
||||
|
||||
### Heartbeat
|
||||
|
||||
`_SSE_HEARTBEAT_INTERVAL_SECONDS = 5` (defined in `api/routes.py`) is the current
|
||||
@@ -162,7 +197,7 @@ gate rather than inventing values without source support.
|
||||
## Authoritative emitted events (`/api/chat/stream`)
|
||||
|
||||
These are the **real wire `event:` names** emitted by `api/streaming.py` today
|
||||
(23 names). Clients and docs must use this table — not the semantic draft above —
|
||||
(24 names). Clients and docs must use this table — not the semantic draft above —
|
||||
when integrating with the live chat SSE relay.
|
||||
|
||||
| Wire name | Role |
|
||||
@@ -179,11 +214,12 @@ when integrating with the live chat SSE relay.
|
||||
| `title` | Session title update (often after `done`) |
|
||||
| `title_status` | Title generation status / skip reason |
|
||||
| `warning` | Non-fatal provider/fallback warning |
|
||||
| `runtime_model` | Local Agent serving identity observed at output or successful completion |
|
||||
| `apperror` | Terminal application error (no trailing `stream_end`) |
|
||||
| `cancel` | Run cancelled |
|
||||
| `done` | Turn finalized (session payload); title/`stream_end` may follow |
|
||||
| `stream_end` | SSE fence — close the client EventSource |
|
||||
| `metering` | Token/cost metering snapshot |
|
||||
| `metering` | Best-effort live token/cost metering snapshot; not journal-replayed |
|
||||
| `context_status` | Context window / usage status |
|
||||
| `goal` | Goal / plan card update |
|
||||
| `goal_continue` | Goal continuation signal |
|
||||
@@ -199,6 +235,31 @@ The semantic taxonomy table remains a draft for the proposed per-session
|
||||
endpoint vocabulary and must be confirmed during maintainer review before that
|
||||
endpoint claims parity.
|
||||
|
||||
### Observed local runtime model
|
||||
|
||||
The local worker emits `runtime_model` with
|
||||
`{session_id, stream_id, model, provider?, fallback_active, phase}`. The IDs
|
||||
refer to the original run-journal owner even if compression rotates the Agent's
|
||||
session. `phase="observed_output"` means the Agent's own model was read at a
|
||||
nonempty token/reasoning callback or after successful completion (including a
|
||||
non-streaming reply or credential self-heal). It does not promise that a turn
|
||||
which emitted partial output will finish successfully. Missing Agent model
|
||||
sends no observation; the configured selection is not used as serving proof.
|
||||
`fallback_active` is true only when the Agent explicitly reports its fallback
|
||||
flag. Consecutive identical observations are deduplicated within the turn.
|
||||
|
||||
Fallback lifecycle warnings invalidate older serving evidence and reset the
|
||||
deduplication; status text alone never establishes the replacement model. A
|
||||
fresh observation after the warning reestablishes it, including when a buffered
|
||||
success notice arrives after output. Durable replay retains the event IDs.
|
||||
The journal summary and HTTP `runtime_journal_snapshot.runtime_model` project
|
||||
the latest valid observation from the same session/stream event window, or null
|
||||
if a warning or malformed latest observation invalidated it. No previous run's
|
||||
footer or selected route fills an unknown current run. This is a local-worker
|
||||
producer; gateway attribution and frontend presentation are separate concerns
|
||||
(see #6272 and #7181). `route_observed` remains reserved for route-only data,
|
||||
not successful output.
|
||||
|
||||
## Cursor and resume semantics
|
||||
|
||||
`Last-Event-ID` is the standard SSE reconnect header. Clients send the last
|
||||
@@ -228,6 +289,31 @@ A future implementation must replay from the run journal via the existing
|
||||
`_replay_run_journal()` path (in `api/routes.py`) and fall back to the
|
||||
snapshot mechanism when journal entries are unavailable for a given cursor.
|
||||
|
||||
### Live-only metering (implemented replay behavior)
|
||||
|
||||
`metering` is best-effort live telemetry, not durable recovery content.
|
||||
`RunJournalWriter.append_sse_event()` returns `None` for metering without
|
||||
writing a row or allocating a sequence number. Other journaled events retain
|
||||
contiguous sequence numbers. Local-agent and gateway producers carry an
|
||||
explicit per-frame event ID, including `None`, through `StreamChannel`;
|
||||
an unjournaled metering frame emits no new SSE `id:` and must not borrow the
|
||||
previous frame's journal ID. The last-ID fallback is limited to legacy
|
||||
two-element queue entries.
|
||||
|
||||
Existing journals are not rewritten. Direct readers (`read_run_events()` and
|
||||
`read_session_run_events()`) retain legacy metering rows for cursor validation
|
||||
and gap/coverage checks. Both run-level and per-session journal replay emitters
|
||||
apply `journal_replay_visible()` to omit those rows from reconnect delivery.
|
||||
Consequently, replayed legacy event IDs can have gaps where metering was
|
||||
filtered; clients must not infer missing durable content from those gaps.
|
||||
Metering missed while disconnected is not recovered from the journal; fresh
|
||||
live updates remain available. This exception does not remove token, reasoning,
|
||||
tool, or terminal events from durable replay.
|
||||
|
||||
Coverage: `tests/test_run_journal.py`, `tests/test_run_journal_routes.py`,
|
||||
`tests/test_issue4812_session_sse_stream.py`, and
|
||||
`tests/test_stage364_opus_live_sse_event_id.py`.
|
||||
|
||||
## Snapshot fallback
|
||||
|
||||
When the `Last-Event-ID` cursor is evicted, expired, unknown, or refers to a
|
||||
|
||||
@@ -574,6 +574,38 @@ early renderers can avoid chasing `S.messages` during every paint. If settlement
|
||||
later rewrites the transcript message, the anchor must be refreshed from that
|
||||
message instead of allowing the two copies to drift silently.
|
||||
|
||||
### Settled ownership of a restored live turn
|
||||
|
||||
A preserved or snapshotted live-turn node is not evidence of a running turn. The
|
||||
settled assistant message is persisted a few milliseconds before the stream's
|
||||
terminal event clears the active stream id, so a live node whose stream has
|
||||
already ended can survive a transcript rebuild or a session switch and be
|
||||
appended under the settled answer, putting the same answer on screen twice
|
||||
(#6948 follow-up; upstream symptom report #2051).
|
||||
|
||||
A live-turn node is therefore discarded only where the settled transcript is
|
||||
proved to own it: the transcript ends with a settled assistant message, no
|
||||
message still carries a live-projection marker, that settled message's
|
||||
persisted stream identity equals the id of the stream that built the live node
|
||||
— or, only where no scene identity was persisted, the markdown the stream
|
||||
produced equals the persisted message source — and the node carries nothing the
|
||||
settled rebuild could not have produced, meaning no unpersisted tool card,
|
||||
reasoning row or transparent-stream row and no second live segment.
|
||||
|
||||
Ownership is a state proof, never a comparison of two rendered DOM trees: the
|
||||
live body comes from the streaming parser and the settled body from the
|
||||
markdown renderer, and an answer that reads the same as the previous one must
|
||||
not delete a genuinely live turn. An unrecognised node shape keeps the turn.
|
||||
Only the two branches that ADD a turn are gated; a branch that replaces a node
|
||||
cannot duplicate one.
|
||||
|
||||
A settled activity scene projects one answer as well. A `process_prose` row
|
||||
whose text duplicates the scene's final answer — the same text, or a prefix of
|
||||
it within ten percent of its length — is dropped where both settled renderers
|
||||
take their rows, so the answer is not rebuilt as a second assistant segment
|
||||
above itself. Live rendering is unchanged: while the turn streams, the inline
|
||||
live segment is hidden and the prose row is the visible answer.
|
||||
|
||||
## Replay, Reload, And Reconstruction
|
||||
|
||||
Replay/reload should reconstruct the same Assistant Turn Anchor from durable
|
||||
|
||||
@@ -215,6 +215,14 @@ settle.
|
||||
Steer must not exist only as a toast or transient DOM state. It must survive
|
||||
refresh, session switch, replay, and settled render.
|
||||
|
||||
**The transport wrapper is not the Steer row.** A Steer is emitted as a standalone
|
||||
typed user row (`role: user`, `display_kind: "steer"`). The surrounding
|
||||
`[OUT-OF-BAND USER MESSAGE …] … [/OUT-OF-BAND USER MESSAGE]` block is transport-level
|
||||
control data: settlement unwraps the single validated frame in place, extracting
|
||||
and preserving the user's authored steer instruction while dropping the wrapper.
|
||||
Legacy tool rows and rows where markers are multiple, nested, incomplete, or
|
||||
contain ambiguous delimiters are preserved byte-for-byte.
|
||||
|
||||
### Relationship to system control events
|
||||
|
||||
Steer is similar to system-delivered control events such as tool-iteration-limit
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
- **Status:** Proposed
|
||||
- **Author:** @franksong2702
|
||||
- **Created:** 2026-05-16
|
||||
- **Updated:** 2026-08-22
|
||||
- **Updated:** 2026-09-15
|
||||
- **Tracking issue:** [#2361](https://github.com/nesquena/hermes-webui/issues/2361)
|
||||
- **Related architecture:** [#1925](https://github.com/nesquena/hermes-webui/issues/1925), [`hermes-run-adapter-contract.md`](hermes-run-adapter-contract.md), [`stable-assistant-turn-anchors.md`](stable-assistant-turn-anchors.md)
|
||||
|
||||
@@ -30,6 +30,69 @@ This RFC defines a consistency contract for those layers. It complements the
|
||||
larger run adapter direction in #1925 by documenting what must remain coherent
|
||||
while WebUI still has multiple overlapping state stores.
|
||||
|
||||
## Agent registration after cancellation
|
||||
|
||||
Initial and credential self-heal Agent construction use the same registration
|
||||
boundary. Under `STREAMS_LOCK`, both the worker-retained cancellation event and
|
||||
live stream membership must permit registration. A removed `CANCEL_FLAGS` entry
|
||||
is not permission to restart. If Stop won during initial or self-heal
|
||||
construction, the candidate must not enter the reusable cache or call
|
||||
`run_conversation`. Stream registration, reusable-cache publication and the
|
||||
in-memory lifecycle handle share one atomic Stop admission, using lock order
|
||||
`STREAMS_LOCK` then `SESSION_AGENT_CACHE_LOCK`. Merely moving a cache write
|
||||
after a cancellation check does not protect that check-to-publication gap.
|
||||
|
||||
After prompt preparation and immediately before each initial/self-heal invocation,
|
||||
revalidate the retained cancel event, stream membership and exact registered
|
||||
Agent. If Stop already won, retire a matching reusable entry only while the
|
||||
existing `SESSION_WRITEBACK_OWNERS` record still equals this exact stream.
|
||||
Hold that ownership lock through cache/lifecycle retirement. Object identity is
|
||||
insufficient because successors can reuse the same Agent; absent ownership is
|
||||
not permission either, since a completed successor clears its record. Never
|
||||
clear a successor's cache or lifecycle handle, and do not issue another Agent
|
||||
interrupt for an invocation that never started. Rejected cache-hit registration
|
||||
likewise must not interrupt the borrowed Agent; only a never-published newly
|
||||
constructed candidate may receive construction-cancellation cleanup. Drop the
|
||||
old worker's local borrowed handle as well, so final pending-Steer drain cannot
|
||||
reach a successor through an object-reference fallback.
|
||||
Stop after invocation admission uses the existing Agent interrupt mechanism;
|
||||
registry locks must not span provider or tool execution. LRU eviction/close stays
|
||||
outside the stream lock and retains the existing active-worker policy.
|
||||
|
||||
Interrupt and cancellation finalization occur outside the stream registry lock.
|
||||
Session finalization still owns the session lock: the returned-error path already
|
||||
holds it, while initial registration and the exception path acquire it. Do not
|
||||
reacquire this non-reentrant lock from a branch that already owns it.
|
||||
|
||||
## Run-journal sequence publication
|
||||
|
||||
Within one WebUI process, auto-numbered appends to the same journal allocate
|
||||
sequence numbers and write their rows under the same per-path lock.
|
||||
`RunJournalWriter` delegates both operations to `append_run_event`; it must not
|
||||
reserve a sequence and release the lock before the physical append. Otherwise
|
||||
individually valid rows can reach disk out of order and the session replay
|
||||
reader must reject them as noncontiguous. This does not change caller-supplied
|
||||
sequence semantics, cross-process ownership, or failed-write recovery.
|
||||
|
||||
## Inactive compression continuation recovery
|
||||
|
||||
The Agent profile's SQLite compression lineage owns the canonical continuation,
|
||||
including when Desktop/CLI compressed a session without updating WebUI's
|
||||
`pre_compression_snapshot` sidecar flag. `GET /api/session` may expose the
|
||||
existing `continuation_session_id` hint from that read-only lineage. Automatic
|
||||
`idle_timeout` closure does not hide the continuation; explicit/unknown terminal
|
||||
reasons, foreign-profile rows and delegated/tool children do not authorize it.
|
||||
This read does not reopen sessions or copy ancestor display history into context.
|
||||
|
||||
A stale `POST /api/chat/start` returns HTTP 409 with `code=session_rotated`
|
||||
and the continuation hint before workspace, model, pending-turn or worker
|
||||
mutation. The browser loads the continuation through normal session access
|
||||
checks and restores the rejected text and attachments as a draft. The user
|
||||
sends again explicitly; there is no automatic POST replay or migration of the
|
||||
parent's workspace binding. Clients without this handling must reload the
|
||||
session before retrying. Server wakeups, regeneration semantics and Gateway
|
||||
routing are not silently retargeted by this recovery path.
|
||||
|
||||
## Goals
|
||||
|
||||
- Define the state layers involved in active and recovered WebUI turns.
|
||||
@@ -75,6 +138,7 @@ and 5; it does not mark every run-state boundary implemented.
|
||||
| Compression summary / handoff | Gives the agent recovery context after automatic compression | Must remain agent-facing recovery material unless explicitly rendered as history | Pollute the active turn or become implicit current user intent |
|
||||
| Live UI scene/cache | Preserves expanded rows, in-progress cards, local scroll, and transient grouping | May optimize presentation but must be rebuildable or degradable from transcript/replay | Become the only place where chronological ordering exists |
|
||||
| Sidebar/session metadata | Helps the user find active and recent sessions | Must reflect meaningful user or assistant activity | Treat background cleanup as a fresh user-facing update |
|
||||
| Client-side unread stores (`localStorage`) | Backs the sidebar unread dot for every client on the origin | Converges counts/markers across clients and stores clear ordering independently per session | Let one client's stale cache lower a count or resurrect a cleared marker |
|
||||
|
||||
## Core Invariants
|
||||
|
||||
@@ -108,10 +172,61 @@ and 5; it does not mark every run-state boundary implemented.
|
||||
do not conflict, and the pairing is unambiguous. Keep the rich sidecar row;
|
||||
if any requirement is missing or contradictory, preserve both rows rather
|
||||
than deduplicating. Literal scalar `[screenshot]` text alone is not identity
|
||||
evidence.
|
||||
evidence. A WebUI-submitted native-image turn has a separate display owner:
|
||||
keep its exact submitted text and attachment in the visible session row,
|
||||
while the Agent's expanded multipart row remains in `context_messages` for
|
||||
model replay. While the turn is active, the WebUI may hide an Agent user row
|
||||
from display only after its worker confirms that the active stream's exact
|
||||
`pending_started_at` value was passed to the Agent as
|
||||
`persist_user_timestamp`; persist that private proof with the session and
|
||||
validate it against the pending stream, source, and timestamp after reload.
|
||||
Never include the proof in public session payloads. This applies to
|
||||
native-image and scalar text-attachment rows, and never changes model
|
||||
context. If multiple user rows share that timestamp, omit the whole
|
||||
ambiguous display bucket until the turn settles; keep all rows in model
|
||||
context. Do not identify the row by its text. If a stream dies before
|
||||
settlement, state.db self-heal must save the submitted prompt and attachments
|
||||
as a visible sidecar row before clearing pending metadata only when there is
|
||||
genuine state.db output beyond that submitted turn. Otherwise, leave pending
|
||||
state intact for journaled partial-output and interruption-marker recovery.
|
||||
The Agent row remains available in model context. A partial continuation
|
||||
must use one consistent parent snapshot when projecting a conflicting
|
||||
provider payload onto its sidecar-owned display row.
|
||||
Match settled native-image scalar projections only with trusted turn and
|
||||
durable-row identity, never the marker alone. A durable row ID proves row
|
||||
identity, not provider-payload freshness: when the sidecar and state.db have
|
||||
conflicting nonempty `api_content`, preserve both versions for model-context
|
||||
replay without mutating either. For visible display, a marked mirror may
|
||||
share the existing sidecar bubble only when its valid durable row ID, exact
|
||||
timestamp, and exact visible user content match; keep the sidecar-owned row
|
||||
and its display metadata. Distinct row IDs, ambiguous or invalid identities,
|
||||
and different visible user text remain separate only while eligible under
|
||||
the existing edit/undo truncation watermark and checkpoint-order rules;
|
||||
removed rows must not reappear in display or model replay. Fill a missing
|
||||
payload from the other copy; repeated reconciliation must remain bounded
|
||||
and idempotent.
|
||||
Agent state.db alone cannot restore the original attachment if the WebUI
|
||||
sidecar is lost.
|
||||
Visible interim assistant progress must remain visible timeline content; a
|
||||
compact Activity disclosure may summarize adjacent tool/debug detail, but it
|
||||
must not be the only place where the user can see emitted progress text.
|
||||
Interim assistant text that duplicates the tail of the accumulated reasoning
|
||||
transcript is stripped from the reasoning copy so the restored snapshot
|
||||
shows the content once. That echo match is whitespace-insensitive and
|
||||
carries no fixed search window: a compact-equivalent suffix is recognized
|
||||
however much interior whitespace stretches its raw span. Both consumers
|
||||
(the live-stream echo path and the journal replay in `api/routes.py`)
|
||||
match through an incremental folded index (`_CompactEchoIndex` in
|
||||
`api/streaming.py`): the folded view and its raw cut offsets are built as
|
||||
each chunk is appended, so a probe costs O(len(candidate)) and never
|
||||
rescans the transcript's whitespace. The retired windowed variants could
|
||||
drop the strip when the span exceeded the window, duplicating the interim
|
||||
text; the retired raw backward walk was correct but re-walked the span per
|
||||
interim event, quadratic on whitespace-heavy transcripts. Regressions:
|
||||
`tests/test_live_snapshot_echo_dedup.py` pins the single-occurrence
|
||||
result, `tests/test_live_snapshot_echo_scan_scaling.py` pins the scaling
|
||||
property (a fixed-size fixture cannot catch a per-interim rescan), and
|
||||
`tests/test_compact_echo_index.py` pins index/oracle equivalence.
|
||||
6. **Compression is not current intent.** Automatic compression summaries and
|
||||
reference cards are recovery/handoff material. They must not be treated as a
|
||||
new user request, active-turn content, or the default visible explanation for
|
||||
@@ -153,6 +268,94 @@ and 5; it does not mark every run-state boundary implemented.
|
||||
timestamp (falling back to run start), so a long-running turn cancelled
|
||||
moments ago is never mistaken for an orphan.
|
||||
|
||||
## Client-side unread persistence (sidebar layer)
|
||||
|
||||
The sidebar unread dot is backed by two client-side stores in `static/sessions.js`.
|
||||
Both live in `localStorage` under the origin, so every WebUI client on the same
|
||||
origin/profile (a PWA window and a browser tab, for example) shares them while each
|
||||
client also caches them in module state. They are projections of the sidebar layer
|
||||
above; the rules below describe what stays coherent when more than one client
|
||||
writes.
|
||||
|
||||
| Store | Key | Semantics |
|
||||
|---|---|---|
|
||||
| Viewed counts | `hermes-session-viewed-counts` | `sid -> {message_count, transcript_generation}`, meaning "seen up to N messages in this transcript generation" |
|
||||
| Completion markers | `hermes-session-completion-unread` | `sid -> {message_count, completed_at, ...}` behind the visible dot |
|
||||
|
||||
- **Viewed counts are generation-scoped.** Session mutation routes increment the
|
||||
persisted `transcript_generation` whenever edit, regenerate, retry, undo, clear,
|
||||
or truncate reduces the visible transcript, and record the retained count as
|
||||
`transcript_generation_baseline`. Both fields survive the bounded `/api/sessions`
|
||||
projection for visible and sidebar-reference rows, including cached responses.
|
||||
A newer generation replaces an older one even
|
||||
when its count is lower; counts are monotonic only within one generation and
|
||||
merge by maximum there. A client first observing a newer generation acknowledges
|
||||
only that retained baseline, so messages added after the shrink remain unread
|
||||
even when the shrink and later growth arrive in one coalesced sidebar refresh.
|
||||
Legacy numeric records are generation zero and migrate to the structured
|
||||
representation on their next save. This prevents an old pre-truncate high-water
|
||||
mark from masking messages added after a transcript reset. A deletion records
|
||||
its own key under
|
||||
`hermes-session-viewed-counts:deleted:v1:<encoded-sid>`; merges drop any count
|
||||
whose session has a live deletion record, so a client that still caches the
|
||||
acknowledgement prunes it instead of writing it back. List membership cannot
|
||||
decide this, because the sidebar filters by profile, project, and source, so an
|
||||
absent row is not evidence of deletion. Deletion records expire on the same
|
||||
7-day policy as clear records, so a tab left open longer than that can re-add a
|
||||
count for a session deleted more than seven days earlier. That consequence is
|
||||
bounded and invisible: the session is no longer listed, so the retained entry
|
||||
produces no indicator, and it is re-examined only on the next deletion or clear.
|
||||
- **Completion markers are ordered by logical stamps, not wall clock.**
|
||||
Markers are add/remove and cannot be max-ordered, so each clear records a stamp
|
||||
under `hermes-session-completion-unread-cleared:v1:<encoded-sid>:<stamp>`.
|
||||
Independent immutable keys mean clients clearing different sessions—or clearing
|
||||
the same session in an interleaved operation—cannot replace newer ordering
|
||||
facts, and a reader folds the maximum per session. Markers carry
|
||||
`unread_order`: the greatest stamp the marker's creator had observed (its own
|
||||
clear state, its cached and stored markers) plus one. A marker whose stamp does
|
||||
not exceed its session's clear stamp loses, so a clear wins the tie when a
|
||||
marker was prepared before it but written after; a completion that happens after
|
||||
the clear observes it and stamps higher, so it still wins. Milliseconds are not
|
||||
used for ordering: a clear and a genuine later completion can share one tick, and
|
||||
a single observed clock cannot order them.
|
||||
- **The previous clear representation is migrated once, then dropped.** The
|
||||
unsuffixed `hermes-session-completion-unread-cleared` whole map is read, its
|
||||
facts are imported as independent records, and the key is removed. Keeping it
|
||||
would retain both of the defects it caused: concurrent clears could replace one
|
||||
another in the shared blob, and the blob grew with every session ever cleared.
|
||||
A client still running the previous revision therefore does not observe clears
|
||||
recorded after the migration; that reload boundary is deliberate, because a
|
||||
dual write cannot make the shared blob concurrency-safe. Versioned records are
|
||||
pruned by age (7 days), or when a newer ordering fact for that session was
|
||||
successfully persisted — never by an in-memory-only superseding clear and never
|
||||
by session existence, because the sidebar list is filtered by profile, project,
|
||||
and source, so an absent row may simply be hidden. They are never part of the
|
||||
marker map consumers read. A clear order is retained in module memory even when
|
||||
storage quota prevents allocating its versioned key; the client still attempts
|
||||
the smaller write that removes the marker from the existing marker map, so a user
|
||||
can dismiss unread state under storage pressure. The failed allocation also
|
||||
leaves any older durable clear record in place so reload does not lose the last
|
||||
persisted ordering fact. Logical clear order and retention time are separate:
|
||||
records compare markers using their order stamp,
|
||||
but the 7-day cap uses the wall-clock time at which the record was written, so
|
||||
a future logical stamp cannot extend retention indefinitely. The in-memory
|
||||
fallback lasts until reload, while successfully persisted records retain the
|
||||
same 7-day policy.
|
||||
- **Cross-client repair, not cache invalidation.** The `storage` listener routes a
|
||||
changed unread key (including any per-session clear key) back through the same
|
||||
merge instead of only dropping the local cache. A client that still holds an
|
||||
acknowledgement re-asserts it after the other client's write, the loser sees a
|
||||
value it cannot beat and stops, and repair converges instead of ping-ponging
|
||||
storage events.
|
||||
- **Whole-map facts converge; clear ordering does not share a map.** Viewed counts
|
||||
and completion markers still use read-modify-write maps, so interleaved writes
|
||||
can transiently lose an entry. Their monotonic cache merge and storage-event
|
||||
repair re-assert held facts. Clear ordering is different: each session has its
|
||||
own atomic `localStorage` write, so concurrent clears of different sessions
|
||||
cannot clobber each other. A viewed-count advance records its clear even when a
|
||||
competing client has prepared but not yet persisted the older completion
|
||||
marker; repeated observations at the same count do not refresh the tombstone.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
Use this checklist for PRs that touch run state, streaming, replay, compression,
|
||||
@@ -175,6 +378,10 @@ context reconstruction, or session metadata:
|
||||
- If it introduces or changes a reclamation window, what proves an in-flight
|
||||
cancellation is not evicted early, and that a wedged one is eventually freed?
|
||||
- Can automatic compression or recovery text become visible active-turn content?
|
||||
- Does this change write one of the client-side unread stores
|
||||
(`hermes-session-viewed-counts`, `hermes-session-completion-unread`,
|
||||
`hermes-session-completion-unread-cleared`), and does it keep the merge and
|
||||
tombstone rules in the client-side unread persistence section?
|
||||
- What test or manual evidence proves the invariant?
|
||||
|
||||
## Existing Issue Map
|
||||
|
||||
@@ -56,6 +56,21 @@ infer it from the status code. Clients that only need session updates should
|
||||
use `/api/session/stream` directly; the gateway probe is only relevant for
|
||||
clients that display CLI/TUI/messaging sessions.
|
||||
|
||||
## Gateway watcher projection lifecycle
|
||||
|
||||
The optional gateway stream's watcher owns a cheap database fingerprint and a
|
||||
timestamp for its last full session projection. While clients are subscribed,
|
||||
an unchanged fingerprint can skip the expensive read until the bounded parity
|
||||
interval. With no subscribers the poll loop parks instead of reading the DB.
|
||||
When the final subscriber leaves (including eviction of a full or failed
|
||||
consumer queue), the watcher invalidates both cache fields. A reconnect must
|
||||
therefore trigger a fresh projection even when the database fingerprint has not
|
||||
changed and the parity interval has not elapsed. Final removal also advances a
|
||||
subscriber epoch: an older in-flight DB read cannot restore the invalidated
|
||||
cache or publish its result to a later subscriber cohort. The subscriber lock
|
||||
protects removal, epoch checks and cache commits, but is not held during DB
|
||||
reads, so disconnect is not blocked by a slow projection.
|
||||
|
||||
## Heartbeats and proxy behavior
|
||||
|
||||
- All long-lived streams emit SSE keepalive comment lines on the
|
||||
|
||||
@@ -6,6 +6,26 @@ If your symptom isn't listed and the diagnostics don't narrow it down, file a bu
|
||||
|
||||
---
|
||||
|
||||
## Requests succeed but access records disappear during agent work
|
||||
|
||||
The server emits `[webui]` JSON access records to its original process stdout,
|
||||
using a private duplicate captured before agent imports. This keeps request and
|
||||
HTTP error logs visible when an in-process tool redirects or closes `sys.stdout`.
|
||||
Logging failures still must not interrupt HTTP responses.
|
||||
|
||||
Check the service's captured stdout (for example, `docker logs <container>` or
|
||||
the journal for your WebUI unit). Verify successful `POST /api/chat/start` and
|
||||
`GET /api/chat/stream` records, not only health probes or rejected requests.
|
||||
Records include `method`, `path`, `status`, and `ms`: elapsed time until response
|
||||
headers, **not** the lifetime of an SSE stream or the agent turn. A missing record
|
||||
before response headers does not distinguish a pending request from a logging
|
||||
failure. Keep the launcher's output destination open for the process lifetime.
|
||||
|
||||
Scheduled cron execution belongs to the Hermes Agent gateway scheduler, not the
|
||||
WebUI HTTP server. Investigate cron outcomes in the gateway's logs and the
|
||||
active Hermes home's `cron/executions.db`; WebUI access records show HTTP cron
|
||||
management requests, not every scheduled execution.
|
||||
|
||||
## "AIAgent not available -- check that hermes-agent is on sys.path"
|
||||
|
||||
**Symptom.** WebUI starts, shows the chat interface, but every chat request fails immediately with this error in the response or the server log. As of v0.51.6 the error includes a diagnostic block with the running Python interpreter, the relevant `sys.path` entries, and the most-common fix; on older versions the message is bare.
|
||||
@@ -211,6 +231,105 @@ For a foreground `python3 bootstrap.py`, stop it with Ctrl-C and start it again.
|
||||
|
||||
---
|
||||
|
||||
## Agent sessions list slowly (or the `state.db` read index is missing)
|
||||
|
||||
**Symptom.** The sidebar's imported/CLI session list takes seconds per refresh on a large Hermes profile, or a log line says a `state.db` read failed. Sessions still appear; nothing is lost.
|
||||
|
||||
**Why.** Every WebUI reader of the agent's `state.db` (session listing, transcript reads, lineage, gateway watcher, cron sidebar, insights, health) opens it strictly read-only (`file:...?mode=ro`). A reader never upgrades to a write-capable handle and never creates an index: on a multi-GiB `messages` table `CREATE INDEX` holds the SQLite writer lock for minutes and stalls the agent streaming into the same WAL database. When the agent's standard `idx_messages_session` index is missing (older agent, hand-rebuilt or re-imported DB), listings degrade to a bounded one-pass pre-aggregation — slower than the indexed seek, but read-only. A read-only open failure propagates to the caller's existing error boundary (the listing returns empty for that profile) instead of silently reopening the file writable.
|
||||
|
||||
**Diagnostic.**
|
||||
|
||||
```bash
|
||||
sqlite3 "file:$HOME/.hermes/state.db?mode=ro" "PRAGMA index_list(messages)"
|
||||
```
|
||||
|
||||
`idx_messages_session` should be listed. If it is not, the agent has not created it and WebUI will not create it for you.
|
||||
|
||||
**Fix.** Create the covering read indexes in an explicit drained maintenance window: stop the agent (and any gateway/cron runner) writing to that `state.db`, then run:
|
||||
|
||||
```bash
|
||||
python3 scripts/ensure_state_db_read_indexes.py --db ~/.hermes/state.db --confirm-drained
|
||||
```
|
||||
|
||||
- `--confirm-drained` is mandatory: it is your assertion that no agent turn is running against the database. The tool does not verify it.
|
||||
- `--lock-file PATH` (optional) additionally holds an exclusive non-blocking lock on `PATH` (`flock` on POSIX, `msvcrt.locking` on Windows) for deployments that serialise agent turns on a lock file; a held lock makes the tool exit without touching the database. Without `--lock-file` no lock primitive is required, so the script runs on native Windows as well. On Windows the lock covers byte 0 of `PATH`; a new or empty lock file is initialised with one byte first (an existing lock file is never rewritten).
|
||||
- The tool opens the database `mode=rw` (never `rwc`): a mistyped path raises instead of creating an empty database. Indexes are created inside one `BEGIN IMMEDIATE` transaction and rolled back on any error.
|
||||
- It is idempotent and prints a JSON status per index (`created` / `existing` / `skipped`). `skipped` means this database's schema lacks a column that index keys on (an older agent); the indexes the schema does support are still created. An existing index with a different table, key shape or collation is reported as `Incompatible index` and never replaced; an index that is not covering (`EXPLAIN QUERY PLAN`) is reported as `Index is not covering`.
|
||||
- Windows UNC profiles (`HERMES_HOME=\\server\share\...`) are supported: readers and this tool build the empty-authority URI `file:////server/share/state.db` that the bundled SQLite accepts.
|
||||
|
||||
**When to file a bug.** File a WebUI bug if the listing stays slow after the tool reports `existing` for `idx_messages_session`, if the tool reports `Incompatible index` on an untouched agent-created database, or if a read-only open fails on a local path. Include the tool's JSON output, the `PRAGMA index_list(messages)` result, and the sanitized error text.
|
||||
|
||||
---
|
||||
|
||||
## 404 after login when password auth is enabled
|
||||
|
||||
**Symptom.** After enabling password authentication (`HERMES_WEBUI_PASSWORD`), logging in redirects to `/sessions` and the browser shows a `404 not found` error instead of the chat interface.
|
||||
|
||||
**Why.** The server-side redirect after login targets `/sessions` (plural), but that path was missing from the explicit SPA-shell allowlist in `handle_get()`. Without auth the bug is invisible because the SPA handles `/sessions` client-side and the server route is never hit — only the server-side post-login redirect exposes it.
|
||||
|
||||
**Fix.** `/sessions` is now included alongside `/` and `/index.html` in the set of paths that serve the SPA shell. No configuration change is needed.
|
||||
|
||||
---
|
||||
|
||||
## "OpenCode Go model picker shows a model that errors when you send" (or is missing newly released models)
|
||||
|
||||
**Symptom.** One of two directions:
|
||||
|
||||
1. A model selected from the OpenCode Go group fails on the first message (`model not found`, `Model is unavailable`, or a region error), even though the picker offered it.
|
||||
2. A newly released OpenCode Go model does not appear in the picker at all, and must be typed into the Custom Model ID box.
|
||||
|
||||
**Why.** The Go picker follows the **live** Go-tier catalog (`https://opencode.ai/zen/go/v1/models`) whenever the installed Hermes Agent is v0.20.5 or newer. That endpoint advertises a superset of what a given tier, key, or region can actually serve — an id can be listed and still fail on send. Conversely, an id the endpoint serves but the WebUI's static fallback list predates is missing when the live path is unavailable (Agent older than v0.20.5, probe failure, or offline); the static list mirrors Hermes core's curated Go catalog and can lag new releases by design.
|
||||
|
||||
**Diagnostic.** Check which path is feeding your picker:
|
||||
|
||||
```bash
|
||||
hermes --version # live path requires >= 0.20.5
|
||||
curl -sS https://opencode.ai/zen/go/v1/models \
|
||||
-H "Authorization: Bearer $OPENCODE_GO_API_KEY" | head -50
|
||||
```
|
||||
|
||||
Interpret the two together:
|
||||
|
||||
- **Failing model absent from the `curl` output** → it was delisted upstream (for example `ox-alpha-free`, removed from the relay on 2026-09-09). A picker can still offer it from a **stale catalog merge**: Hermes core merges its own curated Go list into the live result, and every Agent release *through v0.21.1 (tag `v2026.9.7`)* still carries the delisted id in that list — verified at runtime against v0.21.0, where the live path serves 37 ids including `ox-alpha-free`. The removal is committed on core `main` (2026-09-09 sync) but **no released Agent version includes it yet** — and `main` reports the same `0.21.1` version string as the stale tag, so a version number alone cannot tell you whether the fix is in. Until a release notes the 2026-09-09 catalog sync, the **verified workaround is the config allowlist** (below); selecting the dead entry is harmless to other models (it errors on send, nothing else).
|
||||
- **Failing model present in the `curl` output** → the relay lists it but your tier/region cannot serve it; the picker is behaving correctly. Pin the models you actually use with an explicit allowlist in `config.yaml`, which takes precedence over both the live catalog and the fallback:
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
opencode-go:
|
||||
models:
|
||||
- kimi-k3
|
||||
- glm-5.3
|
||||
```
|
||||
|
||||
A lighter per-provider exclude capability is tracked in #7507.
|
||||
- **Missing new model, Agent ≥ v0.20.5** → the live catalog is the source; refresh or check the endpoint with the `curl` above (cold rebuilds are also bounded by a 4-second foreground budget — the first picker open after a restart can serve the last-known list while the live rebuild finishes in the background, so re-open the picker once before concluding it's stale).
|
||||
- **Missing new model, Agent older than v0.20.5** → the static fallback is serving by design; upgrade the Agent to ≥ v0.20.5 so the picker reads the live catalog.
|
||||
|
||||
**When to file a bug.** File a WebUI bug if a model fails on send *and* appears in the `curl` output for your key (a routing problem), or if a model is missing with Agent ≥ v0.20.5 and the live catalog reachable (fallback used when it should not be).
|
||||
|
||||
---
|
||||
|
||||
## MCP panel shows another profile's servers, or "Live status for this profile is unavailable"
|
||||
|
||||
**Symptom.** With several profiles, the MCP settings panel of profile A shows a server as *Active* with a tool count while the tool inventory is empty (or lists profile B's tools); `/reload-mcp` on one profile stops the other profile's servers; or the MCP panel and the external Notes drawer show the notice *"Live status for this profile is unavailable right now"* and `/reload-mcp` answers *"MCP runtime scope could not be confirmed"*.
|
||||
|
||||
**Why.** Hermes Agent keeps one in-process MCP ledger per WebUI process and keys a connection by profile only when it can tell the request serves a profile other than the process's own. The WebUI binds every MCP status read and `/reload-mcp` to the request profile (`ARCHITECTURE.md` §4.10). While a chat turn is streaming, the WebUI mirrors that turn's profile into `HERMES_HOME`; Agents that predate `hermes_constants.pin_process_hermes_home` cannot distinguish that mirror from the process profile, so the WebUI withholds runtime data and refuses the reload instead of showing or resetting another profile's connection.
|
||||
|
||||
**Diagnostic commands.**
|
||||
|
||||
```bash
|
||||
# runtime_scope: "profile" (bound), "legacy_process" (Agent without profile-scoped MCP),
|
||||
# "unavailable" (scope could not be confirmed right now)
|
||||
curl -s -b "hermes_profile=<profile>" http://127.0.0.1:8787/api/mcp/servers | python3 -m json.tool | grep -E '"(name|status|tool_count|runtime_scope)"'
|
||||
python3 -c "import hermes_constants; print(hasattr(hermes_constants, 'pin_process_hermes_home'))"
|
||||
```
|
||||
|
||||
**Fix.** `unavailable` while a turn is running is expected: refresh once the turn finishes. If it persists with no turn running, the Agent predates the process-home pin; upgrade Hermes Agent. `legacy_process` means the Agent has no profile-scoped MCP ledger at all; its `/reload-mcp` stays process-wide by design. After changing a profile's `mcp_servers`, run `/reload-mcp` **from that profile**: it only resets that profile's own connections and retries its failed servers.
|
||||
|
||||
**When to file a bug.** File a WebUI bug if `runtime_scope` is `"profile"` and a server is still reported *Active* with tools you cannot see in the inventory, or if `/reload-mcp` from one profile changes another profile's `tool_count`.
|
||||
|
||||
---
|
||||
|
||||
## Other troubleshooting
|
||||
|
||||
This document grows over time. If a recurring failure mode isn't covered here yet, add it via PR. The format for each entry: **Symptom → Why → Diagnostic commands → Fix → When to file a bug**.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create the covering read indexes on the agent ``state.db`` in an explicit,
|
||||
drained maintenance window.
|
||||
|
||||
The WebUI read paths (session listing, lineage reads, gateway watcher, cron
|
||||
sidebar, insights, health) never create indexes themselves: ``CREATE INDEX``
|
||||
on a multi-GiB ``messages`` table holds the SQLite writer lock for minutes,
|
||||
which stalls the agent streaming into the same WAL database. Index
|
||||
maintenance is therefore an operator action run while the agent is idle.
|
||||
|
||||
Usage::
|
||||
|
||||
python scripts/ensure_state_db_read_indexes.py --db ~/.hermes/state.db \\
|
||||
--confirm-drained [--lock-file /path/to/agent-activity.lock]
|
||||
|
||||
``--confirm-drained`` is mandatory. When ``--lock-file`` is given, the tool
|
||||
takes an exclusive non-blocking lock on that path (``flock`` on POSIX,
|
||||
``msvcrt.locking`` on Windows) so that a deployment which serialises agent
|
||||
turns on a lock file cannot start a turn mid-rebuild. Without ``--lock-file``
|
||||
no lock primitive is needed, so the script runs on every supported platform.
|
||||
|
||||
Existing indexes are verified for table, key shape and collation and must be
|
||||
covering (``EXPLAIN QUERY PLAN``); an incompatible index is reported, never
|
||||
silently replaced.
|
||||
"""
|
||||
import argparse
|
||||
from contextlib import closing, nullcontext
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
try: # POSIX
|
||||
import fcntl
|
||||
except ImportError: # native Windows
|
||||
fcntl = None
|
||||
try: # Windows
|
||||
import msvcrt
|
||||
except ImportError: # POSIX
|
||||
msvcrt = None
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
from api.agent_sessions import state_db_file_uri # same UNC-aware URI as the readers
|
||||
|
||||
INDEXES = {
|
||||
"idx_messages_session": ("messages", (("session_id", "BINARY"), ("timestamp", "BINARY"))),
|
||||
"idx_messages_session_role": ("messages", (("session_id", "BINARY"), ("role", "NOCASE"))),
|
||||
"idx_sessions_webui_fingerprint": ("sessions", tuple((c, "BINARY") for c in
|
||||
("source", "id", "message_count", "last_activity_at"))),
|
||||
}
|
||||
|
||||
# The only schema gap an older Agent is known to have: sessions.last_activity_at
|
||||
# arrived later than the other keyed columns. An index whose missing columns are
|
||||
# all listed here is reported "skipped"; any other missing column means this is
|
||||
# not an Agent state.db (or a damaged one), and the tool fails closed.
|
||||
_LEGACY_OPTIONAL_COLUMNS = {
|
||||
"idx_sessions_webui_fingerprint": frozenset({"last_activity_at"}),
|
||||
}
|
||||
|
||||
|
||||
def _require_agent_schema(db) -> None:
|
||||
"""Fail closed unless this is an Agent ``state.db``.
|
||||
|
||||
Matching column names alone would accept a look-alike chat database. Every
|
||||
Agent ``state.db`` since the first schema has carried a ``schema_version``
|
||||
row and ``sessions.id`` as the primary key, so require both before any
|
||||
``CREATE INDEX``.
|
||||
"""
|
||||
has_marker = db.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_version'"
|
||||
).fetchone()
|
||||
version = None
|
||||
if has_marker:
|
||||
cols = {r[1] for r in db.execute("PRAGMA table_info(schema_version)")}
|
||||
if "version" in cols:
|
||||
row = db.execute("SELECT version FROM schema_version LIMIT 1").fetchone()
|
||||
version = row[0] if row else None
|
||||
if not isinstance(version, int) or version < 1:
|
||||
raise RuntimeError(
|
||||
"state.db has no Agent schema_version marker; not an Agent state.db, refusing to continue"
|
||||
)
|
||||
pk = [r[1] for r in db.execute("PRAGMA table_info(sessions)") if r[5]]
|
||||
if pk != ["id"]:
|
||||
raise RuntimeError(
|
||||
"state.db sessions table is not keyed on id; not an Agent state.db, refusing to continue"
|
||||
)
|
||||
|
||||
|
||||
def _exclusive_lock(lock_file):
|
||||
if lock_file is None:
|
||||
return nullcontext()
|
||||
if fcntl is None and msvcrt is None:
|
||||
raise RuntimeError("--lock-file needs fcntl (POSIX) or msvcrt (Windows); "
|
||||
"neither is available on this interpreter")
|
||||
handle = open(lock_file, "a+")
|
||||
try:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
else:
|
||||
# Lock one byte at offset 0 without blocking; raises OSError when held.
|
||||
# ``a+`` may have just created an empty file, and Windows cannot lock
|
||||
# a range beyond EOF, so make sure byte 0 exists first. An existing
|
||||
# (deployment-owned) lock file is left untouched. Write the byte on
|
||||
# the descriptor: a text-mode ``handle.write("\n")`` becomes two
|
||||
# bytes (``\r\n``) on native Windows.
|
||||
if os.fstat(handle.fileno()).st_size == 0:
|
||||
os.write(handle.fileno(), b"\0")
|
||||
handle.flush()
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
except BaseException:
|
||||
handle.close()
|
||||
raise
|
||||
return closing(handle)
|
||||
|
||||
|
||||
def ensure_read_indexes(db_path, *, confirmed_drained=False, lock_file=None):
|
||||
if not confirmed_drained:
|
||||
raise RuntimeError("requires --confirm-drained")
|
||||
db_path = Path(db_path).resolve(strict=True)
|
||||
statuses = {}
|
||||
with _exclusive_lock(lock_file):
|
||||
# mode=rw (not rwc): never create a ghost database at a mistyped path.
|
||||
with closing(sqlite3.connect(state_db_file_uri(db_path) + "?mode=rw", uri=True)) as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
# Validate the whole Agent schema before creating anything, so a
|
||||
# wrong database fails closed without a partial index set.
|
||||
_require_agent_schema(db)
|
||||
missing_by_index = {}
|
||||
for name, (table, keys) in INDEXES.items():
|
||||
present = {r[1] for r in db.execute(f"PRAGMA table_info({table})")}
|
||||
if not present:
|
||||
raise RuntimeError(f"state.db has no {table!r} table; refusing to continue")
|
||||
missing = {col for col, _ in keys if col not in present}
|
||||
if missing - _LEGACY_OPTIONAL_COLUMNS.get(name, frozenset()):
|
||||
raise RuntimeError(
|
||||
f"state.db {table!r} table lacks Agent column(s) "
|
||||
f"{sorted(missing)}; not an Agent state.db, refusing to continue"
|
||||
)
|
||||
missing_by_index[name] = missing
|
||||
for name, (table, keys) in INDEXES.items():
|
||||
existing = db.execute("SELECT tbl_name FROM sqlite_master WHERE name=?", (name,)).fetchone()
|
||||
if missing_by_index[name]:
|
||||
# Known legacy gap. A same-named index here cannot be the
|
||||
# shape we expect (it keys on the absent column), so it is
|
||||
# incompatible, not something to skip past.
|
||||
if existing:
|
||||
raise RuntimeError(f"Incompatible index: {name}")
|
||||
statuses[name] = "skipped"
|
||||
continue
|
||||
if existing:
|
||||
actual = tuple((r[2], r[4]) for r in db.execute(f"PRAGMA index_xinfo({name})") if r[5])
|
||||
flags = next((r for r in db.execute(f"PRAGMA index_list({table})") if r[1] == name), None)
|
||||
if existing[0] != table or actual != keys or not flags or flags[2] or flags[4]:
|
||||
raise RuntimeError(f"Incompatible index: {name}")
|
||||
statuses[name] = "existing"
|
||||
else:
|
||||
columns = ", ".join(f"{col} COLLATE {collation}" for col, collation in keys)
|
||||
db.execute(f"CREATE INDEX {name} ON {table}({columns})")
|
||||
statuses[name] = "created"
|
||||
columns = ", ".join(col for col, _ in keys)
|
||||
plan = db.execute(f"EXPLAIN QUERY PLAN SELECT {columns} FROM {table} INDEXED BY {name}").fetchall()
|
||||
if not any(f"COVERING INDEX {name}" in row[3] for row in plan):
|
||||
raise RuntimeError(f"Index is not covering: {name}")
|
||||
db.commit()
|
||||
except BaseException:
|
||||
db.rollback()
|
||||
raise
|
||||
return statuses
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--db", type=Path, required=True, help="path to the agent state.db")
|
||||
parser.add_argument("--lock-file", type=Path, default=None,
|
||||
help="optional lock file to hold exclusively (flock) while indexes are built")
|
||||
parser.add_argument("--confirm-drained", action="store_true",
|
||||
help="assert that no agent turn is running against this database")
|
||||
args = parser.parse_args()
|
||||
result = ensure_read_indexes(args.db, confirmed_drained=args.confirm_drained,
|
||||
lock_file=args.lock_file)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -99,10 +99,12 @@ from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from api.auth import check_auth, reset_trusted_auth_request_state
|
||||
from api.request_logging import emit_request_log
|
||||
from api.auth import check_auth_or_close, reset_trusted_auth_request_state
|
||||
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
|
||||
from api.helpers import (
|
||||
j,
|
||||
advertise_connection_close,
|
||||
get_profile_cookie,
|
||||
_build_csp_report_only_policy,
|
||||
_CLIENT_DISCONNECT_ERRORS,
|
||||
@@ -331,6 +333,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
extra_frame_src = getattr(self, "_csp_extra_frame_src", None)
|
||||
self.send_header("Content-Security-Policy-Report-Only", self.csp_report_only_policy(extra_connect_src, extra_frame_src))
|
||||
self.send_header("Report-To", self._CSP_REPORT_TO)
|
||||
advertise_connection_close(self) # tell the client when the socket dies
|
||||
super().end_headers()
|
||||
|
||||
def log_message(self, fmt, *args): pass # suppress default Apache-style log
|
||||
@@ -338,10 +341,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
@staticmethod
|
||||
def _safe_webui_print(message: str) -> None:
|
||||
"""Emit a request log line without letting logging break responses."""
|
||||
try:
|
||||
print(message, flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
emit_request_log(message)
|
||||
|
||||
def log_request(self, code: str='-', size: str='-') -> None:
|
||||
"""Structured JSON logs for each request."""
|
||||
@@ -378,7 +378,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
set_request_profile(cookie_profile)
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
if not check_auth(self, parsed): return
|
||||
# Body-pending-aware: a body-bearing GET failing auth would poison reuse (#7550).
|
||||
if not check_auth_or_close(self, parsed): return
|
||||
result = handle_get(self, parsed)
|
||||
if result is False:
|
||||
return j(self, {'error': 'not found'}, status=404)
|
||||
@@ -403,10 +404,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
set_request_profile(cookie_profile)
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
_is_csp_report_post = (
|
||||
parsed.path == "/api/csp-report" and self.command == "POST"
|
||||
)
|
||||
if not _is_csp_report_post and not check_auth(self, parsed): return
|
||||
_is_csp_report_post = parsed.path == "/api/csp-report" and self.command == "POST"
|
||||
if not _is_csp_report_post and not check_auth_or_close(self, parsed): return
|
||||
result = route_func(self, parsed)
|
||||
if result is False:
|
||||
return j(self, {'error': 'not found'}, status=404)
|
||||
@@ -670,6 +669,7 @@ def main() -> None:
|
||||
|
||||
_abort_if_already_serving(HOST, PORT)
|
||||
httpd = QuietHTTPServer((HOST, PORT), Handler)
|
||||
from api.gateway_chat import resume_gateway_runs_after_restart; resume_gateway_runs_after_restart() # bound, not yet serving
|
||||
|
||||
from api.config import TLS_ENABLED, TLS_CERT, TLS_KEY
|
||||
scheme = 'https' if TLS_ENABLED else 'http'
|
||||
|
||||
+37
-5
@@ -59,6 +59,7 @@ async function cancelStream(reason){
|
||||
else setStatus('');
|
||||
// /api/chat/cancel only exposes `cancelled:bool`, so we cannot
|
||||
// distinguish reasons — keep the toast generic and short.
|
||||
if(typeof _clearPendingPromptsForSession==='function') _clearPendingPromptsForSession(sid);
|
||||
if(typeof showToast==='function') showToast('Stream is no longer active',2000);
|
||||
}
|
||||
return respOk;
|
||||
@@ -91,6 +92,9 @@ async function cancelSessionStream(session){
|
||||
if(typeof setComposerStatus==='function') setComposerStatus('');
|
||||
else setStatus('');
|
||||
}
|
||||
if(typeof _clearPendingPromptsForSession==='function') _clearPendingPromptsForSession(sid);
|
||||
if(typeof stopApprovalPollingForSession==='function') stopApprovalPollingForSession(sid);
|
||||
if(typeof stopClarifyPollingForSession==='function') stopClarifyPollingForSession(sid);
|
||||
if(typeof _approvalSessionId!=='undefined' && _approvalSessionId===sid){
|
||||
stopApprovalPolling();
|
||||
hideApprovalCard(true);
|
||||
@@ -1901,7 +1905,7 @@ window.renderTranscript=function(container, messages, opts){
|
||||
fetch(new URL('api/tts', document.baseURI || location.href).href, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({text: clean, voice, rate, pitch})
|
||||
body: JSON.stringify({text: clean, voice, rate, pitch, engine: 'edge'})
|
||||
})
|
||||
.then(r => {
|
||||
if(!r.ok) throw new Error('TTS request failed: ' + r.status);
|
||||
@@ -2388,9 +2392,29 @@ window._isImeEnter=_isImeEnter;
|
||||
function _hasFinePointerCoexisting(){
|
||||
try{ return matchMedia('(any-pointer:fine)').matches; }catch(_){ return false; }
|
||||
}
|
||||
// Detect phone software keyboards without undoing #3076's hardware-input
|
||||
// guard for tablets. Some iOS Safari versions report (any-pointer:fine) on a
|
||||
// plain iPhone, so phone UAs bypass that unreliable signal. Tablets and
|
||||
// touch-capable desktop UAs still require a coarse pointer with no fine pointer.
|
||||
function _isTouchOnlyDevice(){
|
||||
const ua=navigator.userAgent||'';
|
||||
if(/iPhone|iPod/i.test(ua)) return true;
|
||||
if(/Android.*Mobile/i.test(ua)) return true;
|
||||
try{
|
||||
return matchMedia('(pointer:coarse)').matches&&!_hasFinePointerCoexisting();
|
||||
}catch(_){}
|
||||
return false;
|
||||
}
|
||||
function _isNumpadEnter(e){
|
||||
return e.key==='Enter'&&(e.code==='NumpadEnter'||e.location===KeyboardEvent.DOM_KEY_LOCATION_NUMPAD);
|
||||
}
|
||||
// Initialise _sendKey synchronously from the localStorage cache so the keydown
|
||||
// handler below has the correct value before the async /api/settings call
|
||||
// (line ~3231) resolves. Without this, on slow mobile networks the race window
|
||||
// leaves _sendKey=undefined, _mobileDefault evaluates false, and plain Enter
|
||||
// falls through to the `else { send() }` branch — sending the message instead
|
||||
// of inserting a newline (issue: mobile Enter sends on fresh page load).
|
||||
try{ window._sendKey=localStorage.getItem('hermes-pref-send_key')||'enter'; }catch(_){ window._sendKey='enter'; }
|
||||
$('msg').addEventListener('keydown',e=>{
|
||||
// Autocomplete navigation when dropdown is open
|
||||
const dd=$('cmdDropdown');
|
||||
@@ -2424,9 +2448,8 @@ $('msg').addEventListener('keydown',e=>{
|
||||
if(e.key==='Enter'){
|
||||
if(_isImeEnter(e)){return;}
|
||||
const isNumpadEnter=_isNumpadEnter(e);
|
||||
const _mobileDefault=matchMedia('(pointer:coarse)').matches
|
||||
&&!_hasFinePointerCoexisting()
|
||||
&&window._sendKey==='enter';
|
||||
const _mobileDefault=_isTouchOnlyDevice()
|
||||
&&(window._sendKey==='enter'||typeof window._sendKey==='undefined');
|
||||
if(window._sendKey==='shift+enter'){
|
||||
if(e.shiftKey){e.preventDefault();send();}
|
||||
} else if(window._sendKey==='ctrl+enter'||_mobileDefault){
|
||||
@@ -3431,8 +3454,17 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
|
||||
localStorage.setItem('hermes-font-size',fontSize);
|
||||
_applyFontSize(fontSize);
|
||||
if(typeof setLocale==='function'){
|
||||
// #7622 (round 3): the settings payload's `s.language` is
|
||||
// absent (None) for a fresh install, so an explicit non-empty
|
||||
// value is the user's genuine saved choice. The browser
|
||||
// navigator hint is now read via the guarded
|
||||
// `_detectBrowserLanguageHint()` helper (round-3 finding 2) so
|
||||
// a throwing `navigator` accessor in some embedded webviews
|
||||
// can no longer abort this branch and reset loaded preferences.
|
||||
// The fallback ternary preserves the pre-#7622 boot behaviour
|
||||
// when neither helper is in scope (defence in depth).
|
||||
const _lang=typeof resolvePreferredLocale==='function'
|
||||
? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'))
|
||||
? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'), _detectBrowserLanguageHint())
|
||||
: (s.language || localStorage.getItem('hermes-lang') || 'en');
|
||||
setLocale(_lang);
|
||||
if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();
|
||||
|
||||
+157
-49
@@ -1,3 +1,7 @@
|
||||
const _WEBUI_DISPATCHABLE_AGENT_COMMANDS = new Set([
|
||||
'reload-mcp','reload-skills','codex-runtime','credits',
|
||||
'moa','sessions','resume','pet'
|
||||
]);
|
||||
// ── Slash commands ──────────────────────────────────────────────────────────
|
||||
// Built-in commands intercepted before send(). Each command runs locally
|
||||
// (no round-trip to the agent) and shows feedback via toast or local message.
|
||||
@@ -182,6 +186,31 @@ function executeCommand(text){
|
||||
return {noEcho:!!cmd.noEcho};
|
||||
}
|
||||
|
||||
// Agent-registry slash commands that the WebUI actually dispatches when
|
||||
// submitted. The autocomplete must not advertise anything outside this set:
|
||||
// a non-dispatchable registry command (e.g. /agents) would otherwise fall
|
||||
// through send() to the ordinary chat path and trigger an unintended
|
||||
// model/API request.
|
||||
//
|
||||
// This set holds canonical registry names only. getMatchingCommands() matches
|
||||
// cmd.name (the canonical metadata name), so alias/underscore forms such as
|
||||
// /reload_mcp are covered implicitly: typing one resolves through
|
||||
// getAgentCommandMetadata() at dispatch time and the canonical name is what
|
||||
// gets tested against the dispatcher allowlist (#6951).
|
||||
//
|
||||
// Keep this in sync with _AGENT_COMMANDS_RUN_ON_WEBUI in messages.js and
|
||||
// _ALLOWED_AGENT_COMMANDS in api/commands.py -- the announced list is a
|
||||
// subset of the dispatched list (fail-closed), never a superset.
|
||||
// Plugin-category commands are always dispatchable via the plugin exec
|
||||
// transport. #6951.
|
||||
|
||||
function _isWebuiDispatchableAgentCommand(cmd){
|
||||
const name=String(cmd&&cmd.name||'').trim().toLowerCase();
|
||||
if(_WEBUI_DISPATCHABLE_AGENT_COMMANDS.has(name))return true;
|
||||
// Plugin-registered commands execute via the /api/commands/exec plugin transport.
|
||||
return String(cmd&&cmd.category||'').trim()==='Plugin';
|
||||
}
|
||||
|
||||
function getMatchingCommands(prefix){
|
||||
const q=prefix.toLowerCase();
|
||||
const matches=COMMANDS.filter(c=>c.name.startsWith(q)).map(c=>({...c,source:'builtin'}));
|
||||
@@ -214,9 +243,14 @@ function getMatchingCommands(prefix){
|
||||
const name=String(cmd&&cmd.name||'').toLowerCase();
|
||||
if(!name.startsWith(q)||seen.has(name))continue;
|
||||
if(cmd.cli_only&&name!=='pet')continue;
|
||||
// #6951: only announce commands send() actually dispatches -- anything
|
||||
// else would silently fall through to the normal chat path as plain text.
|
||||
if(!_isWebuiDispatchableAgentCommand(cmd))continue;
|
||||
matches.push({
|
||||
name,
|
||||
desc:String(cmd&&cmd.description||'').trim()||'Agent command',
|
||||
// Surface the registry's argument hint on the autocomplete row (#6951).
|
||||
arg:String(cmd&&cmd.args_hint||'').trim()||undefined,
|
||||
source:cmd.category==='Plugin'?'plugin':'agent',
|
||||
});
|
||||
seen.add(name);
|
||||
@@ -250,8 +284,15 @@ let _slashPersonalityCachePromise=null;
|
||||
let _bundleCommandCache=[];
|
||||
let _bundleCommandLoadPromise=null;
|
||||
let _bundleCommandCacheReady=false;
|
||||
// Bumped by invalidateSlashSkillCaches(). The two skill-cache loaders below capture
|
||||
// it when they issue /api/skills and refuse to commit when it moved while their
|
||||
// response was in flight: a reply generated for the previous profile would otherwise
|
||||
// land after a profile switch and repopulate the caches with that profile's
|
||||
// disabled-filtered payload, keeping a skill that is enabled in the new profile
|
||||
// hidden (#7509).
|
||||
let _slashSkillCache=null;
|
||||
let _slashSkillCachePromise=null;
|
||||
let _slashSkillCacheGen=0;
|
||||
let _agentCommandCache=null;
|
||||
let _agentCommandCachePromise=null;
|
||||
|
||||
@@ -351,31 +392,47 @@ async function _loadSlashPersonalitySubArgs(force=false){
|
||||
return _slashPersonalityCachePromise;
|
||||
}
|
||||
|
||||
// Disabled skills are excluded from the backend skill-command map, so the slash
|
||||
// picker surfaces must agree. Single gate for every place that turns /api/skills
|
||||
// entries into selectable completions.
|
||||
function _isSkillDisabled(skill){
|
||||
return !!(skill&&skill.disabled);
|
||||
}
|
||||
|
||||
async function _loadSlashSkillSubArgs(force=false){
|
||||
if(_slashSkillCache&&!force) return _slashSkillCache;
|
||||
if(_slashSkillCachePromise&&!force) return _slashSkillCachePromise;
|
||||
const gen=_slashSkillCacheGen;
|
||||
_slashSkillCachePromise=(async()=>{
|
||||
try{
|
||||
const data=await api('/api/skills');
|
||||
const values=[];
|
||||
for(const skill of (data&&data.skills)||[]){
|
||||
if(_isSkillDisabled(skill)) continue;
|
||||
const name=_normalizeSlashSubArg(skill&&skill.name);
|
||||
if(name) values.push(name);
|
||||
}
|
||||
const deduped=Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b));
|
||||
// A profile switch during the request bumped the generation: this payload
|
||||
// belongs to the previous profile, so leave the cache empty for the fresh
|
||||
// read instead of publishing stale names (#7509).
|
||||
if(gen!==_slashSkillCacheGen) return _slashSkillCache||[];
|
||||
_slashSkillCache=deduped;
|
||||
return deduped;
|
||||
}catch(_){
|
||||
_slashSkillCache=null;
|
||||
if(gen===_slashSkillCacheGen) _slashSkillCache=null;
|
||||
return [];
|
||||
}finally{
|
||||
_slashSkillCachePromise=null;
|
||||
if(gen===_slashSkillCacheGen) _slashSkillCachePromise=null;
|
||||
}
|
||||
})();
|
||||
return _slashSkillCachePromise;
|
||||
}
|
||||
|
||||
function invalidateSlashSkillCaches(){
|
||||
// Bump before dropping the caches: an /api/skills response still in flight was
|
||||
// generated for the previous profile and must not commit once it lands (#7509).
|
||||
_slashSkillCacheGen++;
|
||||
_slashSkillCache=null;
|
||||
_slashSkillCachePromise=null;
|
||||
_skillCommandCache=[];
|
||||
@@ -873,6 +930,12 @@ async function _applyManualCompressionResult(data, focusTopic, visibleCount, com
|
||||
clearLiveToolCards();
|
||||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||||
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
|
||||
// Restore paging signals from the (possibly full) transcript response.
|
||||
// A successful /compress returns the full transcript, so the bounded
|
||||
// preflight's _messagesTruncated/_oldestIdx must be reset before render
|
||||
// (#7628). Same restore-before-render ordering as the settle/cancel paths.
|
||||
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!data.session._messages_truncated;
|
||||
if(typeof _oldestIdx!=='undefined') _oldestIdx=data.session._messages_offset||0;
|
||||
syncTopbar();
|
||||
renderMessages();
|
||||
await renderSessionList();
|
||||
@@ -934,6 +997,12 @@ async function resumeManualCompressionForSession(sid){
|
||||
// No active compression job or transient server error — not a real failure.
|
||||
// 404: route missed or session gone; 5xx: backend exception during status check.
|
||||
if(e&&(!e.status||e.status===404||e.status>=500)) return;
|
||||
// #7710: a cross-profile refusal now arrives as 409
|
||||
// (``session_profile_mismatch``) where it used to be a 404 that hit the
|
||||
// benign early-return above. It is not a compression failure, so do not
|
||||
// render the error state or locally settle the compression UI.
|
||||
if(e&&e.status===409&&typeof _sessionProfileMismatchFromError==='function'
|
||||
&&_sessionProfileMismatchFromError(e)) return;
|
||||
if(S.session&&S.session.session_id===sid&&typeof setCompressionUi==='function'){
|
||||
const visibleMessages=_manualCompressionVisibleMessages();
|
||||
setCompressionUi({
|
||||
@@ -965,14 +1034,18 @@ async function _runManualCompression(focusTopic){
|
||||
// Preflight: verify the viewed session still exists before compressing.
|
||||
// This avoids a confusing "not found" toast when the UI is stale.
|
||||
try{
|
||||
const live=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
|
||||
// Bounded tail: a bare preflight used to pull and re-redact the whole
|
||||
// transcript before every manual compression (#7310/#7625). Session
|
||||
// existence + the current tail is all this preflight needs.
|
||||
const live=await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`);
|
||||
if(!live||!live.session||live.session.session_id!==sid){
|
||||
throw new Error('session no longer available');
|
||||
}
|
||||
S.session=live.session;
|
||||
S.messages=live.session.messages||[];
|
||||
S.toolCalls=live.session.tool_calls||[];
|
||||
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=false;
|
||||
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!(live.session._messages_truncated);
|
||||
if(typeof _oldestIdx!=='undefined') _oldestIdx=live.session._messages_offset||0;
|
||||
}catch(preflightErr){
|
||||
if(typeof clearCompressionUi==='function') clearCompressionUi();
|
||||
if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null);
|
||||
@@ -1101,47 +1174,59 @@ async function cmdTheme(args){
|
||||
showToast(t('theme_usage')+themes.join('|')+' | '+skins.join('|')+' | legacy:'+legacyThemes.join('|'));
|
||||
}
|
||||
|
||||
async function cmdSkills(args){
|
||||
try{
|
||||
const data = await api('/api/skills');
|
||||
let skills = data.skills || [];
|
||||
if(args){
|
||||
const q = args.toLowerCase();
|
||||
skills = skills.filter(s =>
|
||||
(s.name||'').toLowerCase().includes(q) ||
|
||||
(s.description||'').toLowerCase().includes(q) ||
|
||||
(s.category||'').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if(!skills.length){
|
||||
const msg = {role:'assistant', content: args ? `No skills matching "${args}".` : 'No skills found.'};
|
||||
S.messages.push(msg); renderMessages(); return;
|
||||
}
|
||||
// Group by category
|
||||
const byCategory = {};
|
||||
skills.forEach(s => {
|
||||
const cat = s.category || 'General';
|
||||
if(!byCategory[cat]) byCategory[cat] = [];
|
||||
byCategory[cat].push(s);
|
||||
});
|
||||
const lines = [];
|
||||
for(const [cat, items] of Object.entries(byCategory).sort()){
|
||||
lines.push(`**${cat}**`);
|
||||
items.forEach(s => {
|
||||
const desc = s.description ? ` — ${s.description.slice(0,80)}${s.description.length>80?'...':''}` : '';
|
||||
lines.push(` \`${s.name}\`${desc}`);
|
||||
// Subcommands owned by the agent's own /skills write-approval handler
|
||||
// (hermes_cli/write_approval_commands.py via gateway/slash_commands.py) — these must
|
||||
// fall through to the normal send path rather than be swallowed by the local search below.
|
||||
// Includes every alias that handler accepts: approve/apply, reject/deny/drop, approval/mode.
|
||||
// Keep in sync with handle_pending_subcommand() — a missing alias is silently swallowed here.
|
||||
const SKILLS_AGENT_SUBCOMMANDS=['pending','approve','apply','reject','deny','drop','diff','approval','mode'];
|
||||
|
||||
function cmdSkills(args){
|
||||
const sub=(args||'').trim().split(/\s+/)[0].toLowerCase();
|
||||
if(SKILLS_AGENT_SUBCOMMANDS.includes(sub)) return false;
|
||||
(async()=>{
|
||||
try{
|
||||
const data = await api('/api/skills');
|
||||
let skills = data.skills || [];
|
||||
if(args){
|
||||
const q = args.toLowerCase();
|
||||
skills = skills.filter(s =>
|
||||
(s.name||'').toLowerCase().includes(q) ||
|
||||
(s.description||'').toLowerCase().includes(q) ||
|
||||
(s.category||'').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if(!skills.length){
|
||||
const msg = {role:'assistant', content: args ? `No skills matching "${args}".` : 'No skills found.'};
|
||||
S.messages.push(msg); renderMessages(); return;
|
||||
}
|
||||
// Group by category
|
||||
const byCategory = {};
|
||||
skills.forEach(s => {
|
||||
const cat = s.category || 'General';
|
||||
if(!byCategory[cat]) byCategory[cat] = [];
|
||||
byCategory[cat].push(s);
|
||||
});
|
||||
lines.push('');
|
||||
const lines = [];
|
||||
for(const [cat, items] of Object.entries(byCategory).sort()){
|
||||
lines.push(`**${cat}**`);
|
||||
items.forEach(s => {
|
||||
const desc = s.description ? ` — ${s.description.slice(0,80)}${s.description.length>80?'...':''}` : '';
|
||||
lines.push(` \`${s.name}\`${desc}`);
|
||||
});
|
||||
lines.push('');
|
||||
}
|
||||
const header = args
|
||||
? `Skills matching "${args}" (${skills.length}):\n\n`
|
||||
: `Available skills (${skills.length}):\n\n`;
|
||||
S.messages.push({role:'assistant', content: header + lines.join('\n')});
|
||||
renderMessages();
|
||||
showToast(t('type_slash'));
|
||||
}catch(e){
|
||||
showToast('Failed to load skills: '+e.message);
|
||||
}
|
||||
const header = args
|
||||
? `Skills matching "${args}" (${skills.length}):\n\n`
|
||||
: `Available skills (${skills.length}):\n\n`;
|
||||
S.messages.push({role:'assistant', content: header + lines.join('\n')});
|
||||
renderMessages();
|
||||
showToast(t('type_slash'));
|
||||
}catch(e){
|
||||
showToast('Failed to load skills: '+e.message);
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function cmdUse(args){
|
||||
@@ -1748,11 +1833,13 @@ async function cmdRetry(){
|
||||
const r=await api('/api/session/retry',{method:'POST',body:JSON.stringify({session_id:activeSid})});
|
||||
if(r&&r.error){showToast(r.error);return;}
|
||||
if(!S.session||S.session.session_id!==activeSid)return;
|
||||
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid));
|
||||
// Bounded tail: a bare reload used to pull and re-redact the whole
|
||||
// transcript on every /retry recovery (#7310/#7625).
|
||||
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid)+'&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1');
|
||||
// #5924 SILENT-race guard: a session switch during the GET await must not let
|
||||
// this recovery apply session A's intent to whatever session is now visible.
|
||||
if(!S.session||S.session.session_id!==activeSid)return;
|
||||
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;renderMessages();}
|
||||
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!(data.session._messages_truncated);if(typeof _oldestIdx!=='undefined')_oldestIdx=data.session._messages_offset||0;renderMessages();}
|
||||
$('msg').value=r.last_user_text||'';if(typeof autoResize==='function')autoResize();
|
||||
// Re-arm the single-shot explicit-pick marker from the captured non-default
|
||||
// pick — but only if it's still safe at fire time (session unchanged, current
|
||||
@@ -1770,8 +1857,10 @@ async function cmdUndo(){
|
||||
const r=await api('/api/session/undo',{method:'POST',body:JSON.stringify({session_id:activeSid})});
|
||||
if(r&&r.error){showToast(r.error);return;}
|
||||
if(!S.session||S.session.session_id!==activeSid)return;
|
||||
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid));
|
||||
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=false;renderMessages();}
|
||||
// Bounded tail: a bare reload used to pull and re-redact the whole
|
||||
// transcript on every /undo recovery (#7310/#7625).
|
||||
const data=await api('/api/session?session_id='+encodeURIComponent(activeSid)+'&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1');
|
||||
if(data&&data.session){S.messages=data.session.messages||[];S.toolCalls=[];if(typeof clearLiveToolCards==='function')clearLiveToolCards();if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!(data.session._messages_truncated);if(typeof _oldestIdx!=='undefined')_oldestIdx=data.session._messages_offset||0;renderMessages();}
|
||||
showToast(`↩ ${t('undid_n_messages')} ${r.removed_count} ${t('undid_messages_suffix')}`);
|
||||
}catch(e){showToast(t('undo_failed')+e.message);}
|
||||
}
|
||||
@@ -2096,6 +2185,7 @@ function _getReservedSlashCommandSlugs(){
|
||||
return reserved;
|
||||
}
|
||||
function _buildSkillCommandEntry(skill){
|
||||
if(_isSkillDisabled(skill))return null;
|
||||
const skillName=String(skill&&skill.name||'').trim();
|
||||
const slug=_skillCommandSlug(skillName);
|
||||
if(!slug)return null;
|
||||
@@ -2117,14 +2207,32 @@ function _buildBundleCommandEntry(bundle){
|
||||
async function loadSkillCommands(force=false){
|
||||
if(_skillCommandCacheReady&&!force)return _skillCommandCache;
|
||||
if(_skillCommandLoadPromise&&!force)return _skillCommandLoadPromise;
|
||||
const gen=_slashSkillCacheGen;
|
||||
let _committed=false;
|
||||
_skillCommandLoadPromise=(async()=>{
|
||||
try{
|
||||
const data=await api('/api/skills');
|
||||
const deduped=new Map();
|
||||
for(const skill of (data&&data.skills)||[]){const entry=_buildSkillCommandEntry(skill);if(entry&&!deduped.has(entry.name))deduped.set(entry.name,entry);}
|
||||
// Bumped by a profile switch while we were awaiting: keep the cache empty
|
||||
// (and not "ready") so the composer's next pass loads the new profile (#7509).
|
||||
if(gen!==_slashSkillCacheGen) return _skillCommandCache;
|
||||
_skillCommandCache=Array.from(deduped.values()).sort((a,b)=>a.name.localeCompare(b.name));
|
||||
}catch(_){_skillCommandCache=[];}
|
||||
finally{_skillCommandCacheReady=true;_skillCommandLoadPromise=null;}
|
||||
_committed=true;
|
||||
}catch(_){
|
||||
if(gen===_slashSkillCacheGen)_skillCommandCache=[];
|
||||
}
|
||||
finally{
|
||||
// Only publish the cache as "ready" after a SUCCESSFUL current-generation
|
||||
// commit. Marking it ready in the failure path (what master does
|
||||
// unconditionally) permanently wedges the picker: a single transient
|
||||
// /api/skills rejection leaves ready=true + cache=[], and
|
||||
// ensureSkillCommandsLoadedForAutocomplete() only retries when
|
||||
// !ready && !promise — so skill commands stay missing until a page
|
||||
// reload even after the API recovers. Clear the promise either way so the
|
||||
// next picker pass can retry.
|
||||
if(gen===_slashSkillCacheGen){_skillCommandCacheReady=_committed;_skillCommandLoadPromise=null;}
|
||||
}
|
||||
return _skillCommandCache;
|
||||
})();
|
||||
return _skillCommandLoadPromise;
|
||||
|
||||
+794
-22
File diff suppressed because it is too large.
Load diff
+7
-8
@@ -26,7 +26,6 @@
|
||||
<script>(function(){try{var t=localStorage.getItem('hermes-theme')||'dark';if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';var c=t==='dark'?'#141425':'#FAF7F0';document.querySelectorAll('meta[name="theme-color"]').forEach(function(m){m.setAttribute('content',c);m.removeAttribute('media');});}catch(e){}})()</script>
|
||||
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
|
||||
<script>(function(){try{if(localStorage.getItem('hermes-webui-sidebar-collapsed')==='1')document.documentElement.dataset.sidebarCollapsed='1';}catch(e){}})()</script>
|
||||
<link rel="preload" href="static/pwa-startup.js?v=__WEBUI_VERSION__" as="script">
|
||||
<script src="static/pwa-startup.js?v=__WEBUI_VERSION__"></script>
|
||||
<script>window.__HERMES_CONFIG__={maxUploadBytes:__MAX_UPLOAD_BYTES__,csrfToken:__CSRF_TOKEN_JSON__};</script>
|
||||
<script>window.__HERMES_WEBUI_BUNDLE_VERSION__='__WEBUI_VERSION__';</script>
|
||||
@@ -592,7 +591,7 @@
|
||||
<span class="voice-mode-indicator" id="voiceModeIndicator"></span>
|
||||
<span class="voice-mode-label" id="voiceModeLabel"></span>
|
||||
</div>
|
||||
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
|
||||
<textarea id="msg" rows="1" enterkeyhint="enter" placeholder="Message Hermes…"></textarea>
|
||||
<div id="composerWorkspaceContext" class="sr-only"></div>
|
||||
<div class="composer-footer">
|
||||
<div class="composer-left">
|
||||
@@ -1504,7 +1503,7 @@
|
||||
<div class="settings-section-head">
|
||||
<div>
|
||||
<div class="settings-section-title" data-i18n="settings_tab_extensions">Extensions</div>
|
||||
<div class="settings-section-meta">Browse, install, and manage WebUI extensions.</div>
|
||||
<div class="settings-section-meta" data-i18n="settings_extensions_meta">Browse, install, and manage WebUI extensions.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extensions-tab-bar" role="tablist">
|
||||
@@ -1528,20 +1527,20 @@
|
||||
</div>
|
||||
<div data-extensions-pane="installed" hidden>
|
||||
<div id="extensionsInstalled" class="extensions-gallery" aria-live="polite">
|
||||
<div class="extensions-loading">Loading installed extensions…</div>
|
||||
<div class="extensions-loading" data-i18n="settings_extensions_installed_loading">Loading installed extensions…</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-extensions-pane="diagnostics" hidden>
|
||||
<div class="settings-section-head" style="padding-top:8px">
|
||||
<div></div>
|
||||
<button class="sm-btn extensions-copy-diagnostics-btn" id="extensionsCopyDiagnosticsBtn" type="button" onclick="copyExtensionsDiagnostics()" disabled>Copy diagnostics</button>
|
||||
<button class="sm-btn extensions-copy-diagnostics-btn" id="extensionsCopyDiagnosticsBtn" type="button" onclick="copyExtensionsDiagnostics()" disabled><span data-i18n="settings_extensions_copy_diagnostics">Copy diagnostics</span></button>
|
||||
</div>
|
||||
<div class="settings-field extensions-trust-note" id="extensionsTrustModel">
|
||||
<label>Trust model</label>
|
||||
<div>Extensions run in the WebUI browser origin and can access the same authenticated APIs as this session. Settings and extension-owned storage are browser-local and not for secrets. Only load trusted local extension directories; disabling an extension takes effect after reload for already-injected assets.</div>
|
||||
<label data-i18n="settings_extensions_trust_model_label">Trust model</label>
|
||||
<div data-i18n="settings_extensions_trust_model_text">Extensions run in the WebUI browser origin and can access the same authenticated APIs as this session. Settings and extension-owned storage are browser-local and not for secrets. Only load trusted local extension directories; disabling an extension takes effect after reload for already-injected assets.</div>
|
||||
</div>
|
||||
<div id="extensionsDiagnostics" class="extensions-diagnostics" aria-live="polite">
|
||||
<div class="extensions-loading">Loading extension diagnostics…</div>
|
||||
<div class="extensions-loading" data-i18n="settings_extensions_installed_loading">Loading extension diagnostics…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+15
-7
@@ -7,7 +7,11 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
var input = document.getElementById('pw');
|
||||
var passkeyBtn = document.getElementById('passkey-login');
|
||||
|
||||
if (!form || !input) return;
|
||||
// #7056: the password input is absent on a passwordless deployment (OIDC-only,
|
||||
// or passkey-only). The form itself still renders and carries the i18n data
|
||||
// attributes, and the passkey button below must still be wired up, so only the
|
||||
// form is required here — every password-specific path is guarded individually.
|
||||
if (!form) return;
|
||||
|
||||
var invalidPw = form.getAttribute('data-invalid-pw') || 'Invalid password';
|
||||
var connFailed = form.getAttribute('data-conn-failed') || 'Connection failed';
|
||||
@@ -59,6 +63,8 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
async function doLogin(e) {
|
||||
e.preventDefault();
|
||||
// No password input on a passwordless deployment: nothing to submit.
|
||||
if (!input) return;
|
||||
var pw = input.value;
|
||||
hideErr();
|
||||
try {
|
||||
@@ -147,12 +153,14 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
passkeyBtn.addEventListener('click', doPasskeyLogin);
|
||||
}
|
||||
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
doLogin(e);
|
||||
}
|
||||
});
|
||||
if (input) {
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
doLogin(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// On page load, probe the server so we can distinguish "can't reach server"
|
||||
// (Tailscale off, wrong network) from "session expired / need to log in".
|
||||
|
||||
+531
-56
@@ -1,3 +1,13 @@
|
||||
const _AGENT_COMMAND_ALIASES = {
|
||||
'reload_mcp': 'reload-mcp',
|
||||
'reload_skills': 'reload-skills',
|
||||
'codex_runtime': 'codex-runtime',
|
||||
'credits': 'credits'
|
||||
};
|
||||
const _AGENT_COMMANDS_RUN_ON_WEBUI = new Set([
|
||||
'reload-mcp','reload-skills','codex-runtime','credits',
|
||||
'reload_mcp','reload_skills','codex_runtime','credits'
|
||||
]);
|
||||
function _markSessionViewed(sid, messageCount) {
|
||||
if(typeof _setSessionViewedCount!=='function' || !sid) return;
|
||||
const next = Number.isFinite(messageCount) ? Number(messageCount) : 0;
|
||||
@@ -1184,7 +1194,6 @@ const _sessionTitleProvisionalBySid = new Map();
|
||||
// their canonical command is registered on the backend (for example
|
||||
// /reload-mcp). Keep this intentionally narrow and include underscore variants
|
||||
// observed by users so typing either form still routes through executeAgentCommand.
|
||||
const _AGENT_COMMANDS_RUN_ON_WEBUI = new Set(['reload-mcp', 'reload_mcp', 'reload-skills', 'reload_skills', 'codex-runtime', 'codex_runtime', 'credits']);
|
||||
|
||||
function _clearStaleBusyStateBeforeSend({compressionRunning=false}={}){
|
||||
if(!S||!S.busy||compressionRunning) return false;
|
||||
@@ -1287,6 +1296,31 @@ function applySessionTitleUpdate(sid, titleText, options={}){
|
||||
// BEFORE slash rewrites (/moa, bundles) mutate the payload and BEFORE
|
||||
// uploadPendingFiles() drains S.pendingFiles — so we restore what the user
|
||||
// actually typed, not the transformed send payload.
|
||||
async function _recoverCompressedSend(error,sid,draftText,filesSnapshot,clearPromise){
|
||||
let payload;
|
||||
try{ payload=JSON.parse(error&&error.body||'{}'); }catch(_){ return false; }
|
||||
const target=payload&&payload.continuation_session_id;
|
||||
if(!error||error.status!==409||!payload||payload.code!=='session_rotated'||typeof target!=='string'||!target||target===sid) return false;
|
||||
// A failed POST has not admitted a turn. Never resend automatically: the
|
||||
// continuation may already be busy, and attachments must remain a draft.
|
||||
if(!S.session||S.session.session_id!==sid) return false;
|
||||
delete INFLIGHT[sid];
|
||||
if(typeof clearInflightState==='function') clearInflightState(sid);
|
||||
if(typeof clearOptimisticSessionStreaming==='function') clearOptimisticSessionStreaming(sid);
|
||||
stopApprovalPolling();stopClarifyPolling();removeThinking();setBusy(false);
|
||||
try{
|
||||
await loadSession(target);
|
||||
if(!S.session||S.session.session_id===sid) return false;
|
||||
// loadSession can lose its navigation race to another tab selection. Never
|
||||
// place the rejected message into that unrelated session's composer.
|
||||
_restoreComposerDraftAfterFailedSend(draftText,filesSnapshot,target,clearPromise);
|
||||
if(S.session.session_id!==target) return true;
|
||||
setComposerStatus('Session resumed. Your message is preserved; send it when ready.');
|
||||
showToast('Session resumed after compression. Your draft is preserved.',4000);
|
||||
return true;
|
||||
}catch(_){ return false; }
|
||||
}
|
||||
|
||||
function _restoreComposerDraftAfterFailedSend(draftText, filesSnapshot, sid, clearPromise){
|
||||
const restore=String(draftText||'');
|
||||
const files=Array.isArray(filesSnapshot)?filesSnapshot.filter(Boolean):[];
|
||||
@@ -1334,7 +1368,7 @@ function _restoreComposerDraftAfterFailedSend(draftText, filesSnapshot, sid, cle
|
||||
} else if(!restoredVisible){
|
||||
// Background failure (sid was never the visible session): no live
|
||||
// composer to read, so persist the captured snapshot — it's the only copy.
|
||||
_saveComposerDraftNow(sid, restore, []);
|
||||
_saveComposerDraftNow(sid, restore, files);
|
||||
}
|
||||
// else: restored the visible composer, then the user switched away — the
|
||||
// session-switch save path already saved sid's composer; skip stale write.
|
||||
@@ -1409,14 +1443,16 @@ async function send(){
|
||||
if(!S.session){await newSession();await renderSessionList();}
|
||||
// Busy-control slash commands must be intercepted HERE, before the
|
||||
// defaultMessageMode routing block, so the user can always type /steer, /interrupt,
|
||||
// /queue, /terminal, /goal, or /yolo while the agent is running and have
|
||||
// /queue, /terminal, /goal, /yolo, or /stop while the agent is running and have
|
||||
// them execute immediately.
|
||||
// Without this intercept they fall through to the queue and execute after
|
||||
// the current turn ends — by which point there is no active stream and
|
||||
// cmdSteer / cmdInterrupt say "No active task to stop."
|
||||
// /stop must cancel the active run immediately instead of being steered
|
||||
// or queued as the literal text "/stop" (#6951).
|
||||
if(text.startsWith('/')&&!literalSlash){
|
||||
const _pc=typeof parseCommand==='function'&&parseCommand(text);
|
||||
if(_pc&&['steer','interrupt','queue','terminal','goal','yolo'].includes(_pc.name)){
|
||||
if(_pc&&['steer','interrupt','queue','terminal','goal','yolo','stop'].includes(_pc.name)){
|
||||
const _bc=COMMANDS.find(c=>c.name===_pc.name);
|
||||
if(_bc){
|
||||
$('msg').value='';autoResize();
|
||||
@@ -1839,6 +1875,7 @@ async function send(){
|
||||
if(typeof renderSessionList==='function') void renderSessionList();
|
||||
return;
|
||||
}
|
||||
if(await _recoverCompressedSend(e,activeSid,_failedSendDraftText,_failedSendFilesSnapshot,_composerDraftClearPromise)) return;
|
||||
const conflictActiveStream=/session already has an active stream/i.test(errMsg);
|
||||
if(conflictActiveStream){
|
||||
delete INFLIGHT[activeSid];
|
||||
@@ -2052,6 +2089,7 @@ function closeLiveStream(sessionId, streamId, source){
|
||||
if(!live) return;
|
||||
if(streamId&&live.streamId!==streamId) return;
|
||||
if(source&&live.source!==source) return;
|
||||
if(typeof live.cancelIdleRecovery==='function') live.cancelIdleRecovery();
|
||||
// Snapshot the current live-turn DOM BEFORE tearing the stream down. The
|
||||
// per-event snapshot (snapshotLiveTurn) only fires on content/tool_complete
|
||||
// SSE events, so switching away during a quiet window (mid tool-exec, silent
|
||||
@@ -2722,11 +2760,18 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
let _currentActivityBurstId=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentActivityBurstId)||0)||0;
|
||||
let _currentLiveSegmentSeq=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentLiveSegmentSeq)||0)||0;
|
||||
let _assistantSegmentSeq=Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].currentLiveSegmentSeq)||0)||0;
|
||||
let _lastRunJournalSeq=reconnecting
|
||||
? Number((INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalSeq)||0)
|
||||
// #7640: the replay floor is only as trustworthy as the recovery state behind
|
||||
// it. A cache that kept the cursor but lost the live assistant projection must
|
||||
// not raise `after_seq`: the server would then replay only the tail (often just
|
||||
// `stream_end`), and the missing journal range never gets a chance to rebuild
|
||||
// the body — the settled footer paints over a blank message until a reload.
|
||||
// Fall back to the zero floor whenever the state cannot be validated.
|
||||
const _replayCursorInflight=reconnecting?INFLIGHT[activeSid]:null;
|
||||
let _lastRunJournalSeq=(typeof _runJournalReplayFloorForInflight==='function')
|
||||
? _runJournalReplayFloorForInflight(_replayCursorInflight)
|
||||
: 0;
|
||||
let _lastRunJournalEventId=reconnecting
|
||||
? String((INFLIGHT[activeSid]&&INFLIGHT[activeSid].lastRunJournalEventId)||'')
|
||||
let _lastRunJournalEventId=(typeof _runJournalReplayEventIdForInflight==='function')
|
||||
? _runJournalReplayEventIdForInflight(_replayCursorInflight)
|
||||
: '';
|
||||
const _STREAM_FADE_MS=620;
|
||||
const _STREAM_FADE_MAX_MS=900;
|
||||
@@ -4808,7 +4853,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
function _smdMediaTailFlushEntry(entry){
|
||||
const chunk=_smdMediaTailEntryChunk(entry);
|
||||
if(!chunk) return;
|
||||
const m=/^MEDIA:([^\s\)\]]+)$/.exec(String(chunk));
|
||||
// #7680 re-gate (9/22): strip backtick wrappers so the bare-token
|
||||
// match below sees a plain ``MEDIA:path`` and the bare class
|
||||
// (no backtick in the exclusion set) captures the full filename
|
||||
// even when the path itself contains a backtick.
|
||||
const normalized = String(chunk).replace(/`MEDIA:([^`\s]+)`/g, 'MEDIA:$1');
|
||||
const m=/^MEDIA:([^\s\)\]]+)$/.exec(normalized);
|
||||
const emitted=!!(m && entry && entry.parent && _smdAppendMediaNode(entry.parent, m[1]));
|
||||
if(!emitted && entry) _smdMediaWriteText(entry.parent, entry.data, entry.baseAddText, entry.writeText, chunk);
|
||||
}
|
||||
@@ -4856,23 +4906,29 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
// Prose runs go through the owning text writer. MEDIA tokens go through
|
||||
// the single-token DOMParser helper only after a delimiter or
|
||||
// reliable filename suffix proves the ref is complete.
|
||||
// #7680 re-gate (9/22): strip backtick wrappers first so the bare
|
||||
// class (no backtick in the exclusion set) captures the full
|
||||
// filename even when the path itself contains a backtick.
|
||||
// The pre-pass replaces `` `MEDIA:path` `` with ``MEDIA:path``
|
||||
// so the wrapped form is consumed before the bare scan.
|
||||
const normalized = combined.replace(/`MEDIA:([^`\s]+)`/g, 'MEDIA:$1');
|
||||
const re=/MEDIA:([^\s\)\]]+)/g;
|
||||
let last=0, m;
|
||||
let unmatchedTail=null;
|
||||
while((m=re.exec(combined))){
|
||||
while((m=re.exec(normalized))){
|
||||
const matchEnd = m.index + m[0].length;
|
||||
if(m.index>last){
|
||||
const slice = combined.slice(last, m.index);
|
||||
const slice = normalized.slice(last, m.index);
|
||||
writeCurrent(slice);
|
||||
}
|
||||
if(matchEnd===combined.length && !_smdMediaRefHasReliableBoundary(m[1])){
|
||||
const candidate = combined.slice(m.index);
|
||||
if(matchEnd===normalized.length && !_smdMediaRefHasReliableBoundary(m[1])){
|
||||
const candidate = normalized.slice(m.index);
|
||||
if(candidate.length < _MEDIA_TAIL_MAX){
|
||||
unmatchedTail = candidate;
|
||||
} else {
|
||||
writeCurrent(candidate);
|
||||
}
|
||||
last = combined.length;
|
||||
last = normalized.length;
|
||||
break;
|
||||
}
|
||||
if(!_smdAppendMediaNode(parent, m[1])) writeCurrent(m[0]);
|
||||
@@ -4880,7 +4936,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}
|
||||
// Tail buffer — hold trailing bytes that look like an unterminated
|
||||
// MEDIA prefix; flush any prose before the partial MEDIA suffix.
|
||||
const rest = combined.slice(last);
|
||||
const rest = normalized.slice(last);
|
||||
if(rest){
|
||||
const tailMatch = /MEDIA:[^\s\)\]]*$/.exec(rest);
|
||||
const prefixTail = tailMatch ? '' : _smdMediaPrefixTail(rest);
|
||||
@@ -5708,12 +5764,82 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
return true;
|
||||
}
|
||||
|
||||
function _bindSidebarIdleRecovery(live){
|
||||
let pending=null;
|
||||
live.cancelIdleRecovery=()=>{
|
||||
if(pending&&pending.timer) clearTimeout(pending.timer);
|
||||
pending=null;
|
||||
};
|
||||
live.recoverFromSidebarIdle=()=>{
|
||||
if(pending||_streamFinalized||_terminalStateReached||_pendingStreamEndRecovery) return;
|
||||
const request={inflight:INFLIGHT[activeSid],timer:null};
|
||||
const isCurrent=()=>pending===request&&!_streamFinalized&&!_terminalStateReached&&
|
||||
LIVE_STREAMS[activeSid]===live&&live.source.readyState===1&&
|
||||
S.session&&S.session.session_id===activeSid&&S.activeStreamId===streamId&&
|
||||
INFLIGHT[activeSid]===request.inflight&&
|
||||
!(typeof _sendInProgress!=='undefined'&&_sendInProgress&&activeSid===_sendInProgressSid);
|
||||
pending=request;
|
||||
// Give the independently delivered terminal frame a short handoff window,
|
||||
// then use canonical session recovery even if the transport stays OPEN.
|
||||
// One ticket spans both timer and request; list refreshes cannot extend it.
|
||||
request.timer=setTimeout(async()=>{
|
||||
request.timer=null;
|
||||
if(!isCurrent()){
|
||||
if(pending===request) live.cancelIdleRecovery();
|
||||
return;
|
||||
}
|
||||
try{
|
||||
let runtimeStatus=null;
|
||||
try{
|
||||
runtimeStatus=await api(
|
||||
`/api/chat/stream/status?stream_id=${encodeURIComponent(streamId)}`,
|
||||
{timeoutMs:8000,retries:0,timeoutToast:false}
|
||||
);
|
||||
}catch(_){
|
||||
// Runtime ownership is authoritative here. If the probe itself
|
||||
// fails, do not infer completion from already-cleared session
|
||||
// fields; surface the existing interrupted-stream path instead.
|
||||
if(isCurrent()) _handleStreamError(live.source);
|
||||
return;
|
||||
}
|
||||
if(!isCurrent()) return;
|
||||
if(!runtimeStatus||typeof runtimeStatus.active!=='boolean'){
|
||||
_handleStreamError(live.source);
|
||||
return;
|
||||
}
|
||||
// Gateway success writeback clears persisted active/pending fields
|
||||
// before post-turn goal evaluation and terminal event emission.
|
||||
// STREAMS-backed status therefore owns this decision: an exact active
|
||||
// runtime must keep its OPEN browser handoff even if the sidebar row
|
||||
// already looks idle.
|
||||
if(runtimeStatus.active) return;
|
||||
const status=await _restoreSettledSession(live.source,{
|
||||
status:true,
|
||||
isCurrent,
|
||||
requestOptions:{timeoutMs:8000,retries:0,timeoutToast:false},
|
||||
preserveVisibleOnShorterTerminalSnapshot:true,
|
||||
});
|
||||
// A stale sidebar response is not permission to terminate a worker
|
||||
// which the authoritative session snapshot still reports as active.
|
||||
if(isCurrent()&&status!=='restored'&&status!=='active') _handleStreamError(live.source);
|
||||
}finally{
|
||||
if(pending===request) live.cancelIdleRecovery();
|
||||
}
|
||||
},1500);
|
||||
};
|
||||
for(const event of ['done','cancel','apperror','stream_end','error']){
|
||||
live.source.addEventListener(event,live.cancelIdleRecovery);
|
||||
}
|
||||
}
|
||||
|
||||
function _wireSSE(source){
|
||||
const existingLive=LIVE_STREAMS[activeSid];
|
||||
if(existingLive&&existingLive.source&&existingLive.source!==source){
|
||||
if(typeof existingLive.cancelIdleRecovery==='function') existingLive.cancelIdleRecovery();
|
||||
try{if(existingLive.source.readyState!==2)existingLive.source.close();}catch(_){ }
|
||||
}
|
||||
LIVE_STREAMS[activeSid]={streamId,source};
|
||||
_bindSidebarIdleRecovery(LIVE_STREAMS[activeSid]);
|
||||
|
||||
// Note on #631 Bug B: the original PR description stated the server
|
||||
// "replays buffered token events" on reconnect, and proposed resetting
|
||||
@@ -5980,7 +6106,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_applyToAnchor('approval',d,e);
|
||||
showApprovalForSession(activeSid, d, d.pending_count || 1);
|
||||
playAttentionSound(_attentionSoundKey(activeSid,'approval',1));
|
||||
sendBrowserNotification('Approval required',d.description||'Tool approval needed',{sid:activeSid});
|
||||
// Browser notification is owned by showApprovalCard()/_notifyPromptCard()
|
||||
// so every surfacing path (SSE, fallback poll, post-respond refresh,
|
||||
// reload) dedupes through the same per-id gate.
|
||||
});
|
||||
|
||||
source.addEventListener('clarify',e=>{
|
||||
@@ -5988,7 +6116,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_applyToAnchor('clarify',d,e);
|
||||
showClarifyForSession(activeSid, d);
|
||||
playAttentionSound(_attentionSoundKey(activeSid,'clarify',1));
|
||||
sendBrowserNotification('Clarification needed',d.question||'Tool clarification needed',{sid:activeSid});
|
||||
// Browser notification is owned by showClarifyCard()/_notifyPromptCard().
|
||||
});
|
||||
|
||||
source.addEventListener('state_saved',e=>{
|
||||
@@ -6640,6 +6768,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
S.session=d.session;
|
||||
const _nextMsgs3018=(d.session.messages||[]).filter(m=>m&&m.role);
|
||||
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(d.session);
|
||||
// Same persist-before-offset trap as settle/cancel: refresh paging
|
||||
// before attaching the projected scene (#7628).
|
||||
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!d.session._messages_truncated;
|
||||
if(typeof _oldestIdx!=='undefined') _oldestIdx=d.session._messages_offset||0;
|
||||
_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);
|
||||
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
|
||||
if(S.session&&S.session.session_id){
|
||||
@@ -6881,6 +7013,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
S.session=sessionPayload;
|
||||
const _nextMsgs3018=(sessionPayload.messages||[]).filter(m=>m&&m.role);
|
||||
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(sessionPayload);
|
||||
// A bounded cancel-recovery reload returns a tail window: keep the
|
||||
// Load-earlier paging gate honest, and refresh _oldestIdx BEFORE
|
||||
// persisting the projected scene (#7310/#7625/#7628).
|
||||
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!sessionPayload._messages_truncated;
|
||||
if(typeof _oldestIdx!=='undefined') _oldestIdx=sessionPayload._messages_offset||0;
|
||||
_attachProjectedAnchorSceneToLastAssistant(_nextMsgs3018);
|
||||
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
|
||||
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
||||
@@ -6902,8 +7039,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(_applyCancelSessionPayload(_cancelSessionPayload)) return;
|
||||
// Fetch latest session from server to get accurate message list (includes cancel status)
|
||||
// This ensures messages stay in sync with server, fixing race condition where local
|
||||
// "*Task cancelled.*" message gets lost when done event overwrites S.messages
|
||||
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);
|
||||
// "*Task cancelled.*" message gets lost when done event overwrites S.messages.
|
||||
// Bounded tail: a bare reload used to pull and re-redact the whole transcript
|
||||
// on every cancel recovery (#7310/#7625).
|
||||
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`);
|
||||
if(data&&data.session) _applyCancelSessionPayload(data.session);
|
||||
}catch(_){
|
||||
// Fallback to local cancel message if API fails
|
||||
@@ -6987,13 +7126,19 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
|
||||
async function _restoreSettledSession(source, options=null){
|
||||
const returnStatus=!!(options&&options.status);
|
||||
const isCurrent=options&&typeof options.isCurrent==='function'?options.isCurrent:null;
|
||||
if(isCurrent&&!isCurrent()) return returnStatus?'stale':false;
|
||||
const preserveVisibleOnShorterTerminalSnapshot=!!(options&&options.preserveVisibleOnShorterTerminalSnapshot);
|
||||
if(_isActiveSession() && S.activeStreamId!==streamId){
|
||||
_closeSource(source);
|
||||
return returnStatus?'stale':false;
|
||||
}
|
||||
try{
|
||||
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);
|
||||
// Bounded tail: a bare reload used to pull and re-redact the whole
|
||||
// transcript on every stream-end settle/reconnect recovery (#7310/#7625).
|
||||
// The Load-earlier paging gate is restored from the response below.
|
||||
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}&messages=1&resolve_model=0&msg_limit=30&expand_renderable=1`,options&&options.requestOptions||{});
|
||||
if(isCurrent&&!isCurrent()) return returnStatus?'stale':false;
|
||||
// Opus #2852 race-fix: if a late `done` event ran the finalize path while
|
||||
// we were awaiting the network roundtrip, bail out — done already settled.
|
||||
if(_streamFinalized) return returnStatus?'restored':true;
|
||||
@@ -7027,6 +7172,16 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const _nextMsgs3018=(session.messages||[]).filter(m=>m&&m.role);
|
||||
const _currentMessages=Array.isArray(S.messages)?S.messages:[];
|
||||
const _currentVisibleMessages=_filterRecoveryControlMessages(_currentMessages || []);
|
||||
// Restore paging from the bounded tail BEFORE attaching/persisting the
|
||||
// projected scene: _persistSettledAnchorScene reads _oldestIdx to
|
||||
// compute the absolute transcript index (#7628).
|
||||
if(typeof _messagesTruncated!=='undefined') _messagesTruncated=!!session._messages_truncated;
|
||||
if(typeof _oldestIdx!=='undefined') _oldestIdx=session._messages_offset||0;
|
||||
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(session);
|
||||
if(S.session&&S.session.session_id){
|
||||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||||
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
|
||||
}
|
||||
const _stagedMessages=_carryForwardEphemeralTurnFields(_currentMessages, _nextMsgs3018);
|
||||
const _currentVisibleEndsWithTerminalMarker=(
|
||||
_currentVisibleMessages.length>0 &&
|
||||
@@ -7042,18 +7197,40 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
return !!stagedKey && stagedKey===currentKey;
|
||||
})
|
||||
);
|
||||
const _preserveCurrentTranscript=preserveVisibleOnShorterTerminalSnapshot&&_stagedMatchesCurrentPrefix;
|
||||
// Bounded settle returns a suffix of the durable transcript, not a
|
||||
// prefix of the currently visible window. On a long session the
|
||||
// prefix check fails and would drop the terminal recovery marker
|
||||
// (#7628). Treat a suffix match the same as a prefix match.
|
||||
const _durableVisibleCount=_currentVisibleEndsWithTerminalMarker
|
||||
? _currentVisibleMessages.length-1
|
||||
: _currentVisibleMessages.length;
|
||||
const _stagedSuffixStart=_durableVisibleCount-_stagedMessages.length;
|
||||
const _stagedMatchesCurrentSuffix=(
|
||||
_stagedMessages.length>0 &&
|
||||
_stagedSuffixStart>=0 &&
|
||||
_stagedMessages.length<_currentVisibleMessages.length &&
|
||||
_currentVisibleEndsWithTerminalMarker &&
|
||||
_stagedMessages.every((message, idx)=>{
|
||||
const stagedKey=_messageIdentityKey(message);
|
||||
const currentKey=_messageIdentityKey(_currentVisibleMessages[_stagedSuffixStart+idx]);
|
||||
return !!stagedKey && stagedKey===currentKey;
|
||||
})
|
||||
);
|
||||
// The server's truncation signal decides the strategy, not an `||`:
|
||||
// a bounded settle returns a SUFFIX of the durable transcript, so
|
||||
// suffix matching is the only correct strategy when truncated. Prefix
|
||||
// preservation is reserved for untruncated snapshots. When repeated
|
||||
// identical turns make BOTH comparisons succeed, preferring the prefix
|
||||
// splices at the wrong offset and silently drops/duplicates rows
|
||||
// (#7628). Never prefer prefix when truncation is active.
|
||||
const _truncatedRecovery=(typeof _messagesTruncated!=='undefined'&&!!_messagesTruncated)||(typeof _oldestIdx!=='undefined'&&!!(_oldestIdx>0));
|
||||
const _preserveCurrentTranscript=preserveVisibleOnShorterTerminalSnapshot&&(_truncatedRecovery?_stagedMatchesCurrentSuffix:_stagedMatchesCurrentPrefix);
|
||||
const _resolvedMessages=_preserveCurrentTranscript
|
||||
? [..._stagedMessages,..._currentVisibleMessages.slice(_stagedMessages.length)]
|
||||
? [..._stagedMessages,..._currentVisibleMessages.slice(_truncatedRecovery?_stagedSuffixStart+_stagedMessages.length:_stagedMessages.length)]
|
||||
: _stagedMessages;
|
||||
S.messages=_filterRecoveryControlMessages(_resolvedMessages || []);
|
||||
_attachProjectedAnchorSceneToLastAssistant(S.messages);
|
||||
if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(S.session);
|
||||
if(S.session&&S.session.session_id){
|
||||
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
|
||||
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
|
||||
}
|
||||
if(typeof _adoptRegenerationRevision==='function')_adoptRegenerationRevision(session);
|
||||
const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages);
|
||||
if(_markerOnlyAssistantError&&typeof showToast==='function') showToast('No response received after context compression. Please retry.',5000,'error');
|
||||
const hasMessageToolMetadata=S.messages.some(m=>{
|
||||
@@ -7236,7 +7413,28 @@ function autoResize(){
|
||||
}
|
||||
const el=$('msg');
|
||||
const _nextValue=String(el.value||'');
|
||||
if(typeof CSS!=='undefined'&&typeof CSS.supports==='function'&&CSS.supports('field-sizing','content')){
|
||||
if(el.style.height) el.style.height='';
|
||||
_composerLastResizeValue=_nextValue;
|
||||
updateSendBtn();
|
||||
return;
|
||||
}
|
||||
const _isAppendOnly=_nextValue.length>_composerLastResizeValue.length&&_nextValue.startsWith(_composerLastResizeValue);
|
||||
// An EMPTY composer has no content to measure, so clear any inline height and
|
||||
// let the CSS `min-height` define the resting size. Measuring instead would
|
||||
// read the PLACEHOLDER's scrollHeight — a long busy/compression hint wraps to
|
||||
// two or three lines and would grow the empty composer (71px for the English
|
||||
// busy hint, 97px for the French compression one) purely because of hint text.
|
||||
// That made the empty height history-dependent on this path: 44px on a fresh
|
||||
// send, but grown after any later resize while empty. The native
|
||||
// `field-sizing` path above always holds the resting height, so clearing here
|
||||
// keeps both paths on the same contract.
|
||||
if(!_nextValue){
|
||||
if(el.style.height) el.style.height='';
|
||||
_composerLastResizeValue=_nextValue;
|
||||
updateSendBtn();
|
||||
return;
|
||||
}
|
||||
const _fitsCurrentHeight=el.scrollHeight<=el.offsetHeight;
|
||||
// Only a direct append at the natural one-row height can skip the height
|
||||
// round trip. Replacements and an already-tall composer must remeasure so the
|
||||
@@ -7246,9 +7444,28 @@ function autoResize(){
|
||||
// read as a bogus pixel number (parseFloat("50%")===50), which would wrongly
|
||||
// enable the fast path and leave the composer stuck tall. Reject anything that
|
||||
// is not exactly "<number>px" so those cases fail closed to the full resize.
|
||||
const _minHeightRaw=_isAppendOnly&&_fitsCurrentHeight?getComputedStyle(el).minHeight:'';
|
||||
const _minHeight=/^(?:\d+(?:\.\d+)?|\.\d+)px$/.test(_minHeightRaw)?parseFloat(_minHeightRaw):NaN;
|
||||
const _isAtMinimumHeight=Number.isFinite(_minHeight)&&el.offsetHeight<=Math.ceil(_minHeight)+1;
|
||||
const _composerStyle=_isAppendOnly&&_fitsCurrentHeight?getComputedStyle(el):null;
|
||||
const _composerPx=(raw)=>/^(?:\d+(?:\.\d+)?|\.\d+)px$/.test(raw==null?'':String(raw))?parseFloat(raw):NaN;
|
||||
const _minHeightRaw=_composerStyle?_composerStyle.minHeight:'';
|
||||
const _minHeight=_composerPx(_minHeightRaw);
|
||||
// The ONE-ROW height the composer naturally settles at is line-height +
|
||||
// vertical padding + borders (~48px at the stock font), which is TALLER than
|
||||
// the CSS min-height (44px). Comparing el.offsetHeight against min-height
|
||||
// alone therefore never matched in a real browser, so this skip was dead code
|
||||
// and EVERY append keystroke paid the height:'auto' round trip below — and
|
||||
// that scrollHeight read forces a synchronous layout of the whole document,
|
||||
// transcript included. The cost grows with the rendered transcript: on a
|
||||
// 758-message transcript (31k DOM nodes) a keystroke measured ~168ms median
|
||||
// echo latency at 6x CPU throttle vs 72ms with the transcript detached.
|
||||
// Accept the natural one-row height too; an oversized composer still takes the
|
||||
// full resize because its offsetHeight exceeds that ceiling by far.
|
||||
const _lineHeight=_composerStyle?_composerPx(_composerStyle.lineHeight):NaN;
|
||||
const _naturalRowHeight=Number.isFinite(_lineHeight)
|
||||
?_lineHeight+(_composerPx(_composerStyle.paddingTop)||0)+(_composerPx(_composerStyle.paddingBottom)||0)
|
||||
+(_composerPx(_composerStyle.borderTopWidth)||0)+(_composerPx(_composerStyle.borderBottomWidth)||0)
|
||||
:NaN;
|
||||
const _rowCeiling=Number.isFinite(_naturalRowHeight)&&Number.isFinite(_minHeight)?Math.max(_minHeight,_naturalRowHeight):_minHeight;
|
||||
const _isAtMinimumHeight=Number.isFinite(_rowCeiling)&&el.offsetHeight<=Math.ceil(_rowCeiling)+1;
|
||||
if(_isAppendOnly&&_fitsCurrentHeight&&_isAtMinimumHeight){
|
||||
_composerLastResizeValue=_nextValue;
|
||||
updateSendBtn();
|
||||
@@ -7412,19 +7629,25 @@ function hideApprovalCard(force=false) {
|
||||
let _approvalSessionId = null;
|
||||
let _approvalCurrentId = null; // approval_id of the card currently shown
|
||||
let _approvalPendingBySession = new Map();
|
||||
const _approvalPromptGenerationBySession = new Map();
|
||||
let _approvalResponding = null;
|
||||
let _approvalClearedOwner = null;
|
||||
let _approvalDisplayedOwner = null;
|
||||
|
||||
const _DISMISSED_APPROVALS_KEY = 'hermes_dismissed_approvals';
|
||||
|
||||
// Dismissed approvals are namespaced by session so that two sessions carrying
|
||||
// the SAME approval_id (e.g. a gateway/run source that reuses externally
|
||||
// supplied IDs across sessions) can't have a dismissal in one session hide the
|
||||
// other's still-pending approval. Stored value is "<sid>\u0000<approval_id>".
|
||||
// Dismissal uses the same injective full-owner tuple as notifications.
|
||||
// Legacy session/ID-only tombstones cannot safely identify a gateway run.
|
||||
function _approvalDismissKey(sid, approvalId) {
|
||||
if (!approvalId) return '';
|
||||
return String(sid || '') + '\u0000' + String(approvalId);
|
||||
const pending = typeof approvalId === 'object' ? approvalId : {approval_id: approvalId};
|
||||
return _promptNotifyKey('approval', sid, pending);
|
||||
}
|
||||
|
||||
function _legacyApprovalDismissKey(sid, approvalId) {
|
||||
const pending = typeof approvalId === 'object' ? approvalId : {approval_id: approvalId};
|
||||
const id = pending && pending.approval_id;
|
||||
return sid && id ? String(sid) + '\0' + String(id) : '';
|
||||
}
|
||||
|
||||
function _getDismissedApprovals() {
|
||||
@@ -7435,7 +7658,23 @@ function _getDismissedApprovals() {
|
||||
function _isApprovalDismissed(sid, approvalId) {
|
||||
const key = _approvalDismissKey(sid, approvalId);
|
||||
if (!key) return false;
|
||||
return _getDismissedApprovals().includes(key);
|
||||
const dismissed = _getDismissedApprovals();
|
||||
if (dismissed.includes(key)) return true;
|
||||
const pending = typeof approvalId === "object" ? approvalId : {approval_id: approvalId};
|
||||
const legacyId = pending && pending.approval_id;
|
||||
const legacyKey = sid && legacyId ? String(sid) + "\0" + String(legacyId) : "";
|
||||
if (!legacyKey || !dismissed.includes(legacyKey)) return false;
|
||||
// Bind an old session/ID tombstone to the currently observed full owner.
|
||||
// This keeps an unresolved dismissal quiet through upgrade without letting
|
||||
// the ambiguous legacy key suppress every future gateway run forever.
|
||||
const migrated = dismissed.filter(item => item !== legacyKey && item !== key);
|
||||
const hasFullOwner = !!(pending && pending.run_id && pending._gateway_mirror_token);
|
||||
// A legacy tombstone cannot identify a gateway run. Consume it rather than
|
||||
// transferring its authority to an unrelated later owner that reused the ID.
|
||||
if (!hasFullOwner) migrated.push(key);
|
||||
try { localStorage.setItem(_DISMISSED_APPROVALS_KEY, JSON.stringify(migrated.slice(-100))); }
|
||||
catch (_) {}
|
||||
return !hasFullOwner;
|
||||
}
|
||||
|
||||
function _markApprovalDismissed(sid, approvalId) {
|
||||
@@ -7466,15 +7705,38 @@ function _approvalPromptBelongsToActiveSession(sid) {
|
||||
function activeSessionHasPendingPromptAttention() {
|
||||
const sid = _promptActiveSessionId();
|
||||
return !!(sid && (
|
||||
_approvalPendingBySession.has(sid) ||
|
||||
(_approvalPendingBySession.has(sid) &&
|
||||
!_isApprovalDismissed(sid, _approvalPendingBySession.get(sid).pending)) ||
|
||||
_clarifyPendingBySession.has(sid)
|
||||
));
|
||||
}
|
||||
|
||||
function _approvalPromptGeneration(sid) {
|
||||
return Number(_approvalPromptGenerationBySession.get(sid) || 0);
|
||||
}
|
||||
|
||||
function _bumpApprovalPromptGeneration(sid) {
|
||||
if (sid) _approvalPromptGenerationBySession.set(sid, _approvalPromptGeneration(sid) + 1);
|
||||
}
|
||||
|
||||
function _rememberApprovalPending(pending, pendingCount) {
|
||||
if (!pending) return null;
|
||||
const sid = pending._session_id || _promptActiveSessionId();
|
||||
if (!sid) return null;
|
||||
const prev = _approvalPendingBySession.get(sid);
|
||||
// A replacement pending entry DISPLACES the previous prompt for this
|
||||
// session: retire the displaced prompt notification-dedupe key so the same
|
||||
// externally supplied ID can legitimately notify again later. A re-render
|
||||
// of the SAME prompt serializes to the same owner key - skip retirement
|
||||
// there so repeated poll ticks stay deduped.
|
||||
if (prev && prev.pending
|
||||
&& _promptNotifyKey("approval", sid, prev.pending) !== _promptNotifyKey("approval", sid, pending)) {
|
||||
_retirePromptNotifyKey("approval", sid, prev.pending);
|
||||
_unmarkApprovalDismissed(sid, prev.pending);
|
||||
_bumpApprovalPromptGeneration(sid);
|
||||
} else if (!prev) {
|
||||
_bumpApprovalPromptGeneration(sid);
|
||||
}
|
||||
const nextPending = {...pending, _session_id: sid};
|
||||
_approvalPendingBySession.set(sid, {pending: nextPending, pendingCount: pendingCount || 1});
|
||||
return sid;
|
||||
@@ -7482,7 +7744,11 @@ function _rememberApprovalPending(pending, pendingCount) {
|
||||
|
||||
function _clearApprovalPendingForSession(sid) {
|
||||
if (sid) {
|
||||
const entry = _approvalPendingBySession.get(sid);
|
||||
_approvalPendingBySession.delete(sid);
|
||||
_bumpApprovalPromptGeneration(sid);
|
||||
if (entry && entry.pending) _unmarkApprovalDismissed(sid, entry.pending);
|
||||
if (entry && entry.pending) _retirePromptNotifyKey('approval', sid, entry.pending);
|
||||
if (typeof syncTopbar === 'function') syncTopbar();
|
||||
}
|
||||
}
|
||||
@@ -7625,8 +7891,9 @@ function showApprovalForSession(sid, pending, pendingCount) {
|
||||
|
||||
function showApprovalCard(pending, pendingCount) {
|
||||
const sid = _rememberApprovalPending(pending, pendingCount);
|
||||
if (pending && pending.approval_id && _isApprovalDismissed(sid, pending)) return;
|
||||
if(typeof _notifyPromptCard==='function') _notifyPromptCard('approval', sid, pending);
|
||||
if (!_approvalPromptBelongsToActiveSession(sid)) return;
|
||||
if (pending && pending.approval_id && _isApprovalDismissed(sid, pending.approval_id)) return;
|
||||
_approvalClearedOwner = null;
|
||||
const keys = pending.pattern_keys || (pending.pattern_key ? [pending.pattern_key] : []);
|
||||
const desc = (pending.description || "") + (keys.length ? " [" + keys.join(", ") + "]" : "");
|
||||
@@ -7685,9 +7952,12 @@ function showApprovalCard(pending, pendingCount) {
|
||||
|
||||
function dismissApprovalCard() {
|
||||
const sid = _approvalSessionId;
|
||||
if (_approvalCurrentId) _markApprovalDismissed(sid, _approvalCurrentId);
|
||||
const entry = _approvalPendingBySession.get(sid);
|
||||
if (entry && entry.pending) _markApprovalDismissed(sid, entry.pending);
|
||||
// Keep the unresolved owner for authoritative resolution/replacement.
|
||||
// Local dismissal hides attention, not the server-owned prompt.
|
||||
if (typeof syncTopbar === 'function') syncTopbar();
|
||||
hideApprovalCard(true);
|
||||
if (sid) _clearApprovalPendingForSession(sid);
|
||||
}
|
||||
|
||||
function _syncApprovalCollapseButton(card) {
|
||||
@@ -7867,8 +8137,14 @@ function startApprovalPolling(sid) {
|
||||
let _approvalEventSource = null;
|
||||
let _approvalSSEHealthTimer = null;
|
||||
let _approvalPollingSessionId = null;
|
||||
// Session whose poller stopped on a profile-mismatch 409; re-armed on focus/visibility.
|
||||
let _approvalProfilePausedSessionId = null;
|
||||
// Bumped on every focus/visibility return. A mismatch 409 for a request that began before
|
||||
// the latest return may predate a cookie switch-back, so it earns one retry before pausing.
|
||||
let _promptPollerFocusEpoch = 0;
|
||||
|
||||
function _startApprovalFallbackPoll(sid) {
|
||||
_approvalProfilePausedSessionId = null;
|
||||
// Run one tick immediately so a session already blocked on a pending approval
|
||||
// shows its card instantly (the removed SSE 'initial' event used to do this);
|
||||
// then poll on the 1500ms cadence. (#3913 SHOULD-FIX)
|
||||
@@ -7878,8 +8154,11 @@ function _startApprovalFallbackPoll(sid) {
|
||||
}
|
||||
if (_approvalFallbackPollInFlight) return;
|
||||
_approvalFallbackPollInFlight = true;
|
||||
const focusEpoch = _promptPollerFocusEpoch;
|
||||
try {
|
||||
const generation = _approvalPromptGeneration(sid);
|
||||
const data = await api("/api/approval/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false});
|
||||
if (_approvalPollingSessionMissingOrMismatched(sid) || _approvalPromptGeneration(sid) !== generation) return;
|
||||
if (data.pending) { showApprovalForSession(sid, data.pending, data.pending_count||1); }
|
||||
else if (!_approvalPollingSessionMissingOrMismatched(sid)) {
|
||||
const _resolvedEntry = _approvalPendingBySession.get(sid);
|
||||
@@ -7891,10 +8170,20 @@ function _startApprovalFallbackPoll(sid) {
|
||||
stopApprovalPollingForSession(sid);
|
||||
}
|
||||
}
|
||||
} catch(e) { /* ignore poll errors */ }
|
||||
finally { _approvalFallbackPollInFlight = false; }
|
||||
} catch(e) {
|
||||
// Another profile owns this session now (e.g. a different tab switched the shared
|
||||
// profile cookie): every further poll would 409, so stop and leave the card as-is.
|
||||
// Only this poller may stop itself: a late 409 from a replaced poller must not kill its successor.
|
||||
if (typeof _sessionProfileMismatchFromError === 'function' && _sessionProfileMismatchFromError(e)
|
||||
&& _approvalPollTimer === pollTimer) {
|
||||
// Focus returned mid-request: the cookie may be back, so retry once (after finally).
|
||||
if (focusEpoch !== _promptPollerFocusEpoch) queueMicrotask(_tick);
|
||||
else { stopApprovalPolling(); _approvalProfilePausedSessionId = sid; }
|
||||
}
|
||||
}
|
||||
finally { if (_approvalPollTimer === pollTimer) _approvalFallbackPollInFlight = false; }
|
||||
};
|
||||
_approvalPollTimer = setInterval(_tick, 1500); // matches the v0.50.247 polling cadence so degraded-mode users see the same responsiveness
|
||||
const pollTimer = _approvalPollTimer = setInterval(_tick, 1500); // matches the v0.50.247 polling cadence so degraded-mode users see the same responsiveness
|
||||
_tick();
|
||||
}
|
||||
|
||||
@@ -8043,17 +8332,37 @@ function _startHiddenActiveStreamPoll(sid) {
|
||||
if (!sid) return;
|
||||
_stopHiddenActiveStreamPoll();
|
||||
_sessionStreamHiddenPollSid = sid;
|
||||
let notFoundCount = 0;
|
||||
let pollTimer = null;
|
||||
const ownsPoll = () => _sessionStreamHiddenPollSid === sid &&
|
||||
_sessionStreamHiddenPollTimer === pollTimer;
|
||||
const tick = () => {
|
||||
// A queued tick/response must not mutate a replacement, even for the same sid.
|
||||
if (!ownsPoll()) return;
|
||||
// Stop conditions: tab became visible (real SSE takes over), session
|
||||
// switched, or we're already rendering a stream.
|
||||
if (typeof document !== 'undefined' && !document.hidden) { _stopHiddenActiveStreamPoll(); return; }
|
||||
if (_sessionStreamHiddenPollSid !== sid) { _stopHiddenActiveStreamPoll(); return; }
|
||||
if (S.activeStreamId) return; // already rendering; wait it out
|
||||
try {
|
||||
fetch(_apiUrl('api/session/status?session_id=' + encodeURIComponent(sid)), {credentials: 'same-origin'})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(r => {
|
||||
if (!ownsPoll()) return null;
|
||||
if (r && r.status === 404) {
|
||||
// Profile visibility also returns 404. Bound repeated misses, but
|
||||
// retain the resume owner so a profile flip back can self-heal.
|
||||
if (++notFoundCount >= 3) _stopHiddenActiveStreamPoll();
|
||||
return null;
|
||||
}
|
||||
notFoundCount = 0;
|
||||
if (r && r.status === 410) {
|
||||
if (_sessionStreamHiddenSid === sid) _sessionStreamHiddenSid = null;
|
||||
_stopHiddenActiveStreamPoll();
|
||||
return null;
|
||||
}
|
||||
return r && r.ok ? r.json() : null;
|
||||
})
|
||||
.then(d => {
|
||||
if (!d || _sessionStreamHiddenPollSid !== sid) return;
|
||||
if (!d || !ownsPoll()) return;
|
||||
const streamId = d.active_stream_id;
|
||||
if (streamId && S.activeStreamId !== String(streamId)) {
|
||||
// Server-initiated turn in flight while hidden → attach as replay.
|
||||
@@ -8086,10 +8395,11 @@ function _startHiddenActiveStreamPoll(sid) {
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
} catch (_) {}
|
||||
.catch(() => { notFoundCount = 0; });
|
||||
} catch (_) { notFoundCount = 0; }
|
||||
};
|
||||
_sessionStreamHiddenPollTimer = setInterval(tick, 6000);
|
||||
pollTimer = setInterval(tick, 6000);
|
||||
_sessionStreamHiddenPollTimer = pollTimer;
|
||||
// Fire one immediately so a turn already running when we go hidden is caught
|
||||
// without waiting a full interval.
|
||||
tick();
|
||||
@@ -8434,16 +8744,36 @@ let _clarifyMissingEndpointWarned = false;
|
||||
let _clarifyCountdownTimer = null;
|
||||
let _clarifyExpiresAt = 0;
|
||||
let _clarifyPendingBySession = new Map();
|
||||
const _clarifyPromptGenerationBySession = new Map();
|
||||
const CLARIFY_MIN_VISIBLE_MS = 30000;
|
||||
|
||||
function _clarifyPromptBelongsToActiveSession(sid) {
|
||||
return !!(sid && _promptActiveSessionId() === sid);
|
||||
}
|
||||
|
||||
function _clarifyPromptGeneration(sid) {
|
||||
return Number(_clarifyPromptGenerationBySession.get(sid) || 0);
|
||||
}
|
||||
|
||||
function _bumpClarifyPromptGeneration(sid) {
|
||||
if (sid) _clarifyPromptGenerationBySession.set(sid, _clarifyPromptGeneration(sid) + 1);
|
||||
}
|
||||
|
||||
function _rememberClarifyPending(pending) {
|
||||
if (!pending) return null;
|
||||
const sid = pending._session_id || _promptActiveSessionId();
|
||||
if (!sid) return null;
|
||||
const prev = _clarifyPendingBySession.get(sid);
|
||||
// Mirror of the approval displacement retirement above: a NEW clarification
|
||||
// replaces the session pending entry, so the displaced prompt dedupe key
|
||||
// retires (same-owner re-renders keep their key).
|
||||
if (prev && prev.pending
|
||||
&& _promptNotifyKey("clarify", sid, prev.pending) !== _promptNotifyKey("clarify", sid, pending)) {
|
||||
_retirePromptNotifyKey("clarify", sid, prev.pending);
|
||||
_bumpClarifyPromptGeneration(sid);
|
||||
} else if (!prev) {
|
||||
_bumpClarifyPromptGeneration(sid);
|
||||
}
|
||||
const nextPending = {...pending, _session_id: sid};
|
||||
_clarifyPendingBySession.set(sid, {pending: nextPending});
|
||||
return sid;
|
||||
@@ -8451,11 +8781,19 @@ function _rememberClarifyPending(pending) {
|
||||
|
||||
function _clearClarifyPendingForSession(sid) {
|
||||
if (sid) {
|
||||
const entry = _clarifyPendingBySession.get(sid);
|
||||
_clarifyPendingBySession.delete(sid);
|
||||
_bumpClarifyPromptGeneration(sid);
|
||||
if (entry && entry.pending) _retirePromptNotifyKey('clarify', sid, entry.pending);
|
||||
if (typeof syncTopbar === 'function') syncTopbar();
|
||||
}
|
||||
}
|
||||
|
||||
function _clearPendingPromptsForSession(sid) {
|
||||
_clearApprovalPendingForSession(sid);
|
||||
_clearClarifyPendingForSession(sid);
|
||||
}
|
||||
|
||||
function _hideClarifyCardIfOwner(sid, force=false, reason="dismissed") {
|
||||
if (!sid || _clarifySessionId === sid) hideClarifyCard(force, reason);
|
||||
}
|
||||
@@ -8743,6 +9081,7 @@ function _clarifySetControlsDisabled(disabled, loading=false) {
|
||||
|
||||
function showClarifyCard(pending) {
|
||||
const sid = _rememberClarifyPending(pending);
|
||||
if(typeof _notifyPromptCard==='function') _notifyPromptCard('clarify', sid, pending);
|
||||
if (!_clarifyPromptBelongsToActiveSession(sid)) return;
|
||||
const question = pending.question || pending.description || '';
|
||||
const choices = Array.isArray(pending.choices_offered)
|
||||
@@ -8911,6 +9250,19 @@ async function respondClarify(response) {
|
||||
// not tear B down on A's late 409. The SSE/poll path will re-render the
|
||||
// next prompt's card from scratch via ``showClarifyCard`` either way.
|
||||
if (e && e.status === 409) {
|
||||
// #7710: a cross-profile refusal now also arrives as 409
|
||||
// (``session_profile_mismatch``). The prompt is NOT expired — the write
|
||||
// was refused because the session belongs to another profile. Treating
|
||||
// it as expired would hide a live clarification card and mislabel the
|
||||
// cause, so leave the card standing and report the real reason.
|
||||
if (typeof _sessionProfileMismatchFromError === 'function'
|
||||
&& _sessionProfileMismatchFromError(e)) {
|
||||
_clarifySetControlsDisabled(false, false);
|
||||
if (typeof setStatus === "function") {
|
||||
setStatus("Clarify: session belongs to a different profile");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_clarifyId === clarifyId) {
|
||||
// Same card still showing — dismiss it and rescue the typed draft.
|
||||
// Order matters: ``_stashClarifyDraft`` (called from
|
||||
@@ -8957,6 +9309,7 @@ var _clarifyFallbackTimer = null;
|
||||
var _clarifyHealthTimer = null;
|
||||
let _clarifyFallbackPollInFlight = false;
|
||||
let _clarifyPollingSessionId = null;
|
||||
let _clarifyProfilePausedSessionId = null;
|
||||
|
||||
function startClarifyPolling(sid) {
|
||||
stopClarifyPolling();
|
||||
@@ -8979,6 +9332,7 @@ function startClarifyPolling(sid) {
|
||||
|
||||
function _startClarifyFallbackPoll(sid) {
|
||||
_clarifyPollingSessionId = sid || null;
|
||||
_clarifyProfilePausedSessionId = null;
|
||||
// Run one tick immediately so a session already blocked on a pending clarify
|
||||
// shows its card instantly (the removed SSE 'initial' event used to do this);
|
||||
// then poll on the 3000ms cadence. (#3913 SHOULD-FIX)
|
||||
@@ -8988,8 +9342,11 @@ function _startClarifyFallbackPoll(sid) {
|
||||
}
|
||||
if (_clarifyFallbackPollInFlight) return;
|
||||
_clarifyFallbackPollInFlight = true;
|
||||
const focusEpoch = _promptPollerFocusEpoch;
|
||||
try {
|
||||
const generation = _clarifyPromptGeneration(sid);
|
||||
const data = await api("/api/clarify/pending?session_id=" + encodeURIComponent(sid),{timeoutToast:false});
|
||||
if (!S.session || S.session.session_id !== sid || _clarifyPromptGeneration(sid) !== generation) return;
|
||||
if (data.pending) { showClarifyForSession(sid, data.pending); }
|
||||
else { _clearClarifyPendingForSession(sid); _hideClarifyCardIfOwner(sid, false, 'expired'); }
|
||||
} catch(e) {
|
||||
@@ -9007,6 +9364,16 @@ function _startClarifyFallbackPoll(sid) {
|
||||
currentSessionId: currentSid,
|
||||
message: msg,
|
||||
};
|
||||
// Profile-mismatch 409: the session is owned by another profile under the current
|
||||
// cookie, so polling can never succeed. Stop quietly; the live card stays standing.
|
||||
// Only this poller may stop itself: a late 409 from a replaced poller must not kill its successor.
|
||||
if (typeof _sessionProfileMismatchFromError === "function" && _sessionProfileMismatchFromError(e)) {
|
||||
if (_clarifyFallbackTimer === pollTimer) {
|
||||
if (focusEpoch !== _promptPollerFocusEpoch) queueMicrotask(_tick);
|
||||
else { stopClarifyPolling(); _clarifyProfilePausedSessionId = sid; }
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A 404 from the active session domain is a STALE-SESSION signal — e.g.
|
||||
// the old profile's session still polling briefly after a profile switch,
|
||||
// or a session deleted server-side — NOT a missing clarify endpoint. Stop
|
||||
@@ -9055,10 +9422,10 @@ function _startClarifyFallbackPoll(sid) {
|
||||
console.warn("[clarify] pending poll failed", logDetails);
|
||||
}
|
||||
} finally {
|
||||
_clarifyFallbackPollInFlight = false;
|
||||
if (_clarifyFallbackTimer === pollTimer) _clarifyFallbackPollInFlight = false;
|
||||
}
|
||||
};
|
||||
_clarifyFallbackTimer = setInterval(_tick, 3000);
|
||||
const pollTimer = _clarifyFallbackTimer = setInterval(_tick, 3000);
|
||||
_tick();
|
||||
}
|
||||
|
||||
@@ -9075,6 +9442,21 @@ function stopClarifyPolling() {
|
||||
_clarifyPollingSessionId = null;
|
||||
}
|
||||
|
||||
// Another tab may have switched the shared profile cookie back: re-arm pollers that a
|
||||
// profile-mismatch 409 paused while their session is still open. A still-mismatched
|
||||
// profile just 409s once and pauses them again.
|
||||
function _resumeProfilePausedPromptPollers() {
|
||||
if (typeof document !== 'undefined' && document.hidden) return;
|
||||
_promptPollerFocusEpoch++;
|
||||
const current = (S.session && S.session.session_id) || null;
|
||||
const approvalSid = _approvalProfilePausedSessionId;
|
||||
const clarifySid = _clarifyProfilePausedSessionId;
|
||||
if (approvalSid && approvalSid === current && !_approvalPollTimer) startApprovalPolling(approvalSid);
|
||||
if (clarifySid && clarifySid === current && !_clarifyFallbackTimer) startClarifyPolling(clarifySid);
|
||||
}
|
||||
document.addEventListener('visibilitychange', _resumeProfilePausedPromptPollers);
|
||||
window.addEventListener('focus', _resumeProfilePausedPromptPollers);
|
||||
|
||||
// ── Notifications and Sound ──────────────────────────────────────────────────
|
||||
|
||||
function _completionNotificationPreviewText(lastAssistantMessage, options){
|
||||
@@ -9208,6 +9590,81 @@ function requestNotificationPermission(){
|
||||
return p;
|
||||
});
|
||||
}
|
||||
const _promptNotifySeen = new Map();
|
||||
// Prompt-card notifications: an approval or clarify card BLOCKS the run until
|
||||
// it is answered, so every surfacing path must notify — the live SSE event,
|
||||
// the 1.5s fallback poll, the post-respond "next approval" refresh, and a
|
||||
// page reload while a prompt is still pending. The chokepoint is the card
|
||||
// renderer itself (showApprovalCard / showClarifyCard); this helper dedupes
|
||||
// per logical OWNER - the typed tuple [kind, sid, prompt id, run owner id,
|
||||
// mirror token] serialized with JSON.stringify so delimiter-bearing producer
|
||||
// strings cannot collide (the gateway accepts approval_id as an unrestricted
|
||||
// string) - so repeated poll ticks, re-renders, and session switches ping
|
||||
// exactly once per owner. The run-owner fields mirror the approval-owner
|
||||
// identity used elsewhere in this file (_approvalOwnerForPending): the same
|
||||
// externally supplied approval_id may legitimately be pending in two
|
||||
// sessions, or in two gateway runs within one session, and each owner must
|
||||
// notify on its own. Seen entries are retired by PROMPT LIFECYCLE, not by
|
||||
// wall-clock age: _clearApprovalPendingForSession() and
|
||||
// _clearClarifyPendingForSession() (the resolution/dismissal/terminal
|
||||
// chokepoints) call _retirePromptNotifyKey(), so a prompt left pending for
|
||||
// hours never re-notifies, while a resolved prompt whose ID is later reused
|
||||
// legitimately notifies again. Entries are also NOT recorded when
|
||||
// notifications are disabled, so enabling mid-prompt re-notifies that owner.
|
||||
function _promptNotifyKey(kind, sid, pending){
|
||||
const p = pending || {};
|
||||
// Older clarify producers can omit clarify_id. Their stable prompt payload is
|
||||
// still a usable lifecycle identity and is retired with the pending entry.
|
||||
const id = p.approval_id || p.clarify_id || (kind === 'clarify'
|
||||
? JSON.stringify([p.question || p.description || '', p.choices_offered || p.choices || [], p.requested_at || ''])
|
||||
: '');
|
||||
const runId = String(p.run_id || '').trim();
|
||||
const mirrorToken = String(p._gateway_mirror_token || '').trim();
|
||||
return JSON.stringify([kind, String(sid || ''), String(id),
|
||||
runId && mirrorToken ? runId : '',
|
||||
runId && mirrorToken ? mirrorToken : '']);
|
||||
}
|
||||
function _retirePromptNotifyKey(kind, sid, pending){
|
||||
if (!pending) return;
|
||||
const key = _promptNotifyKey(kind, sid, pending);
|
||||
if (!key) return;
|
||||
_promptNotifySeen.delete(key);
|
||||
}
|
||||
function _notifyPromptCard(kind, sid, pending){
|
||||
const p = pending || {};
|
||||
const id = p.approval_id || p.clarify_id || (kind === 'clarify' ? p.question || p.description || '' : '');
|
||||
if (!id) return;
|
||||
const key = _promptNotifyKey(kind, sid, p);
|
||||
if (_promptNotifySeen.has(key)) return;
|
||||
// Suppress ONLY when the user is effectively looking at this prompt right
|
||||
// now: it belongs to the session open in the pane AND the tab is visible
|
||||
// AND focused. Every other combination pings:
|
||||
// 1. prompt for a non-active session (even with the tab focused)
|
||||
// 2. active session, but a different tab is selected
|
||||
// 3. active session, tab active, but the Chrome window is not focused
|
||||
// Suppression is NOT recorded, so the same prompt still pings exactly once
|
||||
// the moment the user is no longer looking at it.
|
||||
if (typeof _isSessionActivelyViewed === 'function' && _isSessionActivelyViewed(sid)) return;
|
||||
if (typeof sendBrowserNotification !== 'function') return;
|
||||
if (typeof window !== 'undefined' && !window._notificationsEnabled) return;
|
||||
const attempt = {};
|
||||
_promptNotifySeen.set(key, attempt);
|
||||
// forceHidden: this caller already made the visibility decision (the
|
||||
// actively-viewed gate above). sendBrowserNotification's own live gate
|
||||
// ("notify only when document.hidden") would otherwise veto the
|
||||
// unfocused-but-visible case, which this feature explicitly notifies for.
|
||||
const rollback = () => {
|
||||
if (_promptNotifySeen.get(key) === attempt) _promptNotifySeen.delete(key);
|
||||
};
|
||||
try {
|
||||
const delivery = kind === 'clarify'
|
||||
? sendBrowserNotification('Clarification needed', p.question || p.description || 'Tool clarification needed', { sid, forceHidden: true })
|
||||
: sendBrowserNotification('Approval required', p.description || p.command || 'Tool approval needed', { sid, forceHidden: true });
|
||||
Promise.resolve(delivery).then(accepted => {
|
||||
if (accepted !== true) rollback();
|
||||
}, rollback);
|
||||
} catch (_) { rollback(); }
|
||||
}
|
||||
function sendBrowserNotification(title,body,options={}){
|
||||
const force=!!(options&&options.force);
|
||||
// #4416: `forceHidden` means the caller already determined the tab was hidden
|
||||
@@ -9220,14 +9677,32 @@ function sendBrowserNotification(title,body,options={}){
|
||||
if(!force&&!window._notificationsEnabled) return;
|
||||
if(!force&&!forceHidden&&!_isBackgroundedForBrowserNotification()) return;
|
||||
if(!('Notification' in window)) return;
|
||||
if(Notification.permission==='granted'){
|
||||
_showPwaNotification(title,body,options).catch(()=>{try{new Notification(title||assistantDisplayName(),_notificationOptions(body,options));}catch(_err){}});
|
||||
}else if(Notification.permission==='denied'){
|
||||
const deliver = async () => {
|
||||
try {
|
||||
await _showPwaNotification(title,body,options);
|
||||
return true;
|
||||
} catch (_) {
|
||||
try {
|
||||
new Notification(title||assistantDisplayName(),_notificationOptions(body,options));
|
||||
return true;
|
||||
} catch (_err) { return false; }
|
||||
}
|
||||
};
|
||||
if(Notification.permission==='granted') return deliver();
|
||||
if(Notification.permission==='denied'){
|
||||
// Explicit "Send test" (force) deserves feedback instead of a silent no-op.
|
||||
if(force&&typeof showToast==='function') showToast(t('notifications_denied'),3500,'error');
|
||||
}else{
|
||||
requestNotificationPermission().then(p=>{if(p==='granted') _showPwaNotification(title,body,options).catch(()=>{try{new Notification(title||assistantDisplayName(),_notificationOptions(body,options));}catch(_err){}});});
|
||||
return false;
|
||||
}
|
||||
// Permission still 'default': prompt-card owners retry on every 1.5s poll
|
||||
// tick while pending, so an automatic (non-gesture) request is made at most
|
||||
// once per page — otherwise each tick re-prompts and re-toasts "denied".
|
||||
// The explicit "Send test" (force) path always asks.
|
||||
if(!force){
|
||||
if(sendBrowserNotification._autoPermissionRequested) return false;
|
||||
sendBrowserNotification._autoPermissionRequested=true;
|
||||
}
|
||||
return requestNotificationPermission().then(p => p==='granted' ? deliver() : false).catch(() => false);
|
||||
}
|
||||
|
||||
// ── /btw ephemeral stream ────────────────────────────────────────────────────
|
||||
|
||||
+6
-7
@@ -104,16 +104,15 @@ function _jumpToMessage(rawIdx) {
|
||||
}
|
||||
|
||||
// Row is outside the render window — reload the full session and retry.
|
||||
// Use the bare messages=1 path (no msg_limit) so the server returns the
|
||||
// COMPLETE transcript: the target row is addressed by absolute index
|
||||
// (msg-user-<rawIdx>), so a bounded tail window would miss early rows.
|
||||
// (A previous version sent msg_limit=9999 as a "give me everything" hack,
|
||||
// but the server now clamps msg_limit, so the bare path is the correct way
|
||||
// to request the full transcript here.)
|
||||
// Request the COMPLETE transcript via the explicit msg_limit=all escape
|
||||
// hatch: the target row is addressed by absolute index (msg-user-<rawIdx>),
|
||||
// so a bounded tail window would miss early rows. The bare no-limit shape
|
||||
// is not used here intentionally — full-transcript intent must be explicit
|
||||
// so recovery paths stay bounded (#7310/#7625).
|
||||
if (typeof api !== 'function') return;
|
||||
if (S.busy || S.activeStreamId) return;
|
||||
api('/api/session?session_id=' + encodeURIComponent(sid) +
|
||||
'&messages=1&resolve_model=0')
|
||||
'&messages=1&resolve_model=0&msg_limit=all')
|
||||
.then(function(data) {
|
||||
if (!data || !data.session) return;
|
||||
if (!S.session || S.session.session_id !== sid) return; // session switched
|
||||
|
||||
+134
-46
@@ -25,6 +25,7 @@ let _currentCronDetail = null; // full cron job object
|
||||
let _currentCronDetailKey = '';
|
||||
let _cronMode = 'empty'; // 'empty' | 'read' | 'create' | 'edit'
|
||||
let _cronPreFormDetail = null; // snapshot of prior selection when entering a form
|
||||
let _cronModelPickerTouched = false; // true once the user changes the model picker in the current form
|
||||
let _showAllCronProfiles = false;
|
||||
let _cronOtherProfileCount = 0;
|
||||
let _currentWorkspaceDetail = null; // { path, name, is_default }
|
||||
@@ -1507,11 +1508,50 @@ function editCurrentCron(){
|
||||
if (!_currentCronDetail) return;
|
||||
openCronEdit(_currentCronDetail);
|
||||
}
|
||||
function _cronScheduleForEdit(job){
|
||||
// #7352: when the user opens an existing job for edit or duplicate, the
|
||||
// editable field must hold the canonical Agent-parseable schedule, not
|
||||
// the human-readable ``schedule_display`` ("once at ..."). The Agent
|
||||
// parser at cron/jobs.py rejects the display form with ValueError and
|
||||
// the resulting round-trip has been failing with HTTP 500 since the
|
||||
// initial WebUI release.
|
||||
if(!job) return '';
|
||||
const sched = job.schedule;
|
||||
// Only the "once at ..." label is unparseable; every other schedule_display
|
||||
// form the Agent emits (`every monday 9am`, `every 30m`, ...) round-trips
|
||||
// through parse_schedule() AND is what the user actually typed. Prefer it
|
||||
// over the canonical `expr`, otherwise editing or duplicating a
|
||||
// natural-language recurring job silently rewrites it to raw cron
|
||||
// (`every monday 9am` -> `0 9 * * 1`) — the Agent rebuilds schedule_display
|
||||
// from whatever we submit, so the rewrite sticks.
|
||||
const display = job.schedule_display;
|
||||
const displayIsParseable = Boolean(display) && !/^\s*once at\s+/i.test(display);
|
||||
if(sched && typeof sched === 'object'){
|
||||
// One-shot: schedule_display is purely presentation; run_at is what the
|
||||
// parser accepts.
|
||||
if(sched.kind === 'once' && sched.run_at) return sched.run_at;
|
||||
if(displayIsParseable) return display;
|
||||
// Recurring cron: the current Agent schema is ``expr``; some legacy
|
||||
// payloads still expose ``expression`` — accept either.
|
||||
if(sched.expr) return sched.expr;
|
||||
if(sched.expression) return sched.expression;
|
||||
if(sched.run_at) return sched.run_at;
|
||||
}
|
||||
// Final fallback: only use schedule_display if it isn't the "once at ..."
|
||||
// presentation label (which the parser rejects). For any other text
|
||||
// (e.g. interval/every-30m) the display form is also a valid input.
|
||||
if(displayIsParseable){
|
||||
return display;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function duplicateCurrentCron(){
|
||||
if (!_currentCronDetail) return;
|
||||
const job = _currentCronDetail;
|
||||
if (typeof switchPanel === 'function' && _currentPanel !== 'tasks') switchPanel('tasks');
|
||||
_cronPreFormDetail = { ...job };
|
||||
_cronModelPickerTouched = false;
|
||||
_editingCronId = null;
|
||||
_cronMode = 'create';
|
||||
_cronIsDuplicate = true;
|
||||
@@ -1529,7 +1569,7 @@ function duplicateCurrentCron(){
|
||||
}
|
||||
_renderCronForm({
|
||||
name: dupName,
|
||||
schedule: job.schedule_display || (job.schedule && job.schedule.expression) || '',
|
||||
schedule: _cronScheduleForEdit(job),
|
||||
prompt: job.prompt || '',
|
||||
deliver: job.deliver || 'local',
|
||||
profile: job.profile || '',
|
||||
@@ -1568,6 +1608,7 @@ let _cronDeliveryOptionsCache=null;
|
||||
function openCronCreate(){
|
||||
if (typeof switchPanel === 'function' && _currentPanel !== 'tasks') switchPanel('tasks');
|
||||
_cronPreFormDetail = _currentCronDetail ? { ..._currentCronDetail } : null;
|
||||
_cronModelPickerTouched = false;
|
||||
_editingCronId = null;
|
||||
_cronMode = 'create';
|
||||
_cronIsDuplicate = false;
|
||||
@@ -1585,12 +1626,13 @@ function openCronCreate(){
|
||||
function openCronEdit(job){
|
||||
if (!job) return;
|
||||
_cronPreFormDetail = { ...job };
|
||||
_cronModelPickerTouched = false;
|
||||
_editingCronId = job.id;
|
||||
_cronMode = 'edit';
|
||||
_cronSelectedSkills = Array.isArray(job.skills) ? [...job.skills] : [];
|
||||
_renderCronForm({
|
||||
name: job.name || '',
|
||||
schedule: job.schedule_display || (job.schedule && job.schedule.expression) || '',
|
||||
schedule: _cronScheduleForEdit(job),
|
||||
prompt: job.prompt || '',
|
||||
deliver: job.deliver || 'local',
|
||||
profile: job.profile || '',
|
||||
@@ -1817,6 +1859,9 @@ async function _populateCronFormModelSelect(selectedModel, selectedProvider, dis
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
sel.dataset.loaded = '1';
|
||||
// Track deliberate picker changes so an untouched "Default" can keep a
|
||||
// hidden provider-only pin on save (see _cronProviderForClear).
|
||||
sel.addEventListener('change', () => { _cronModelPickerTouched = true; });
|
||||
} catch (e) {
|
||||
console.warn('Failed to load cron model picker:', e.message);
|
||||
// Load failed: dataset.loaded stays unset so saveCronForm omits model/provider
|
||||
@@ -1902,6 +1947,18 @@ function _cronModelBareName(model, provider) {
|
||||
return _modelBareNameForProvider(model, provider);
|
||||
}
|
||||
|
||||
function _cronProviderForClear(prevDetail, pickerTouched) {
|
||||
// Provider to submit when the picker shows "Default" (no model) on save.
|
||||
// An explicit return to Default honors "Default = no overrides" (#4030) and
|
||||
// clears the provider. An untouched picker must not: provider-only jobs
|
||||
// (a pinned provider with no model) render as "Default" in the combined
|
||||
// picker, so saving any other field would silently erase the pin the user
|
||||
// never touched.
|
||||
const prev = prevDetail || {};
|
||||
if (pickerTouched) return null;
|
||||
return (prev.model == null) ? prev.provider : null;
|
||||
}
|
||||
|
||||
async function saveCronForm(){
|
||||
const nameEl=$('cronFormName');
|
||||
const schEl=$('cronFormSchedule');
|
||||
@@ -1939,8 +1996,11 @@ async function saveCronForm(){
|
||||
updates.model = _cronModelBareName(modelState.model, modelState.model_provider) || null;
|
||||
updates.provider = modelState.model_provider || null;
|
||||
} else if (modelLoaded) {
|
||||
// "Default" selected (no model). An untouched picker preserves a
|
||||
// hidden provider-only pin; a deliberate return to Default clears
|
||||
// it (#4030 "Default = no overrides").
|
||||
updates.model = null;
|
||||
updates.provider = null;
|
||||
updates.provider = _cronProviderForClear(_cronPreFormDetail, _cronModelPickerTouched);
|
||||
}
|
||||
// else: select not yet populated — omit model/provider to preserve saved value
|
||||
}
|
||||
@@ -5355,8 +5415,13 @@ function _renderExternalNotesSources() {
|
||||
const recall = data.automatic_recall_unchanged !== false
|
||||
? `<div class="memory-detail-mtime">${esc(t('external_notes_auto_recall_hint'))}</div>`
|
||||
: '';
|
||||
// Same withheld-runtime state as the MCP panel: sources still list, but their
|
||||
// live status/tools are hidden until the profile's runtime scope is confirmed.
|
||||
const scopeNotice = data.runtime_scope === 'unavailable'
|
||||
? `<div class="memory-detail-mtime">${esc(t('mcp_runtime_scope_unavailable'))}</div>`
|
||||
: '';
|
||||
if (!sources.length) {
|
||||
body.innerHTML = `<div class="main-view-content">${recall}<div class="memory-empty">${esc(t('external_notes_empty'))}</div></div>`;
|
||||
body.innerHTML = `<div class="main-view-content">${recall}${scopeNotice}<div class="memory-empty">${esc(t('external_notes_empty'))}</div></div>`;
|
||||
} else {
|
||||
const selected = sources.find(src => (src.name || '').toLowerCase() === (_notesSelectedSource || '').toLowerCase()) || sources[0];
|
||||
_notesSelectedSource = (selected && selected.name) || 'joplin';
|
||||
@@ -5403,7 +5468,7 @@ function _renderExternalNotesSources() {
|
||||
${searchError}
|
||||
${resultHtml}
|
||||
</section>`;
|
||||
body.innerHTML = `<div class="main-view-content">${recall}${recentAiHtml}${searchUi}${previewHtml}${cards}</div>`;
|
||||
body.innerHTML = `<div class="main-view-content">${recall}${scopeNotice}${recentAiHtml}${searchUi}${previewHtml}${cards}</div>`;
|
||||
}
|
||||
body.style.display = '';
|
||||
if (empty) empty.style.display = 'none';
|
||||
@@ -7070,6 +7135,15 @@ async function switchToProfile(name) {
|
||||
if (typeof _resetCronUnreadForProfileSwitch === 'function') {
|
||||
_resetCronUnreadForProfileSwitch();
|
||||
}
|
||||
// #7509: the slash-skill caches hold the previous profile's disabled-filtered
|
||||
// /api/skills payload, so drop them once the switch has actually succeeded —
|
||||
// otherwise a skill that is enabled in the new profile stays hidden behind the
|
||||
// old payload. Invalidating here (rather than before the POST) also kills any
|
||||
// response still in flight, so the previous profile's reply can't repopulate
|
||||
// the caches after this point (see invalidateSlashSkillCaches in commands.js).
|
||||
if (typeof window !== 'undefined' && typeof window.invalidateSlashSkillCaches === 'function') {
|
||||
window.invalidateSlashSkillCaches();
|
||||
}
|
||||
const targetActiveProfile = S.activeProfile || 'default';
|
||||
let sessionProfileMatchesTarget = true;
|
||||
if (!sessionInProgress && S.session) {
|
||||
@@ -9253,8 +9327,17 @@ async function loadSettingsPanel(){
|
||||
_setHiddenTabs(hiddenTabs);
|
||||
_applyTabVisibility(hiddenTabs);
|
||||
_renderTabVisibilityChips();
|
||||
// #7622 (round 3): the settings payload's `settings.language` is
|
||||
// absent (None) for a fresh install, so an explicit non-empty
|
||||
// value is the user's genuine saved choice. The browser
|
||||
// navigator hint is now read via the guarded
|
||||
// `_detectBrowserLanguageHint()` helper (round-3 finding 2) so
|
||||
// a throwing `navigator` accessor can no longer abort settings
|
||||
// hydration before model, provider, plugin and extension sections
|
||||
// are populated. The fallback ternary preserves the pre-#7622
|
||||
// settings-modal behaviour when neither helper is in scope.
|
||||
const resolvedLanguage=(typeof resolvePreferredLocale==='function')
|
||||
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'))
|
||||
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'), _detectBrowserLanguageHint())
|
||||
: (settings.language || localStorage.getItem('hermes-lang') || 'en');
|
||||
// Keep settings modal and current page strings in sync with the resolved locale.
|
||||
if(typeof setLocale==='function'){
|
||||
@@ -9766,31 +9849,31 @@ async function loadSettingsPanel(){
|
||||
// ── Extensions panel (browser-origin diagnostics + local enable controls) ──
|
||||
|
||||
function _extensionStatusLabel(value){
|
||||
return value ? 'Enabled' : 'Disabled';
|
||||
return value ? t('plugins_enabled') : t('plugins_disabled');
|
||||
}
|
||||
|
||||
function _extensionBooleanBadge(value){
|
||||
const cls=value?'extension-status-badge-on':'extension-status-badge-off';
|
||||
return `<span class="extension-status-badge ${cls}">${value?'true':'false'}</span>`;
|
||||
return `<span class="extension-status-badge ${cls}">${value?t('ext_status_true'):t('ext_status_false')}</span>`;
|
||||
}
|
||||
|
||||
function _extensionAssetList(urls){
|
||||
if(!Array.isArray(urls)||urls.length===0){
|
||||
return '<div class="extension-url-empty">None</div>';
|
||||
return '<div class="extension-url-empty">'+t('ext_none')+'</div>';
|
||||
}
|
||||
return '<ul class="extension-url-list">'+urls.map(url=>`<li><code>${esc(url)}</code></li>`).join('')+'</ul>';
|
||||
}
|
||||
|
||||
function _extensionWarningList(warnings){
|
||||
if(!Array.isArray(warnings)||warnings.length===0){
|
||||
return '<div class="extension-url-empty">No warnings.</div>';
|
||||
return '<div class="extension-url-empty">'+t('ext_no_warnings')+'</div>';
|
||||
}
|
||||
return '<ul class="extension-warning-list">'+warnings.map(item=>{
|
||||
const rawCode=(item&&item.code)||'unknown_warning';
|
||||
const code=esc(rawCode);
|
||||
const source=esc((item&&item.source)||'unknown');
|
||||
const hint=rawCode==='extension_state_unknown_ids'
|
||||
? '<span>Some saved disabled-extension overrides no longer match the current manifest; re-added extensions with the same id may stay disabled.</span>'
|
||||
? '<span>'+t('ext_state_unknown_ids_hint')+'</span>'
|
||||
: '';
|
||||
return `<li><code>${code}</code><span>${source}</span>${hint}</li>`;
|
||||
}).join('')+'</ul>';
|
||||
@@ -9890,8 +9973,8 @@ function _extensionConfigureButton(entry,surface){
|
||||
function _extensionInstalledList(extensions,extensionDirConfigured,surface){
|
||||
const list=Array.isArray(extensions)?extensions:[];
|
||||
if(!list.length){
|
||||
if(!extensionDirConfigured) return '<div class="extension-url-empty">No extension directory is configured.</div>';
|
||||
return '<div class="extension-url-empty">No manifest extensions are installed in the configured bundle.</div>';
|
||||
if(!extensionDirConfigured) return '<div class="extension-url-empty">'+t('settings_extensions_no_dir')+'</div>';
|
||||
return '<div class="extension-url-empty">'+t('settings_extensions_installed_empty')+'</div>';
|
||||
}
|
||||
return `<div class="extension-installed-list">${list.map(entry=>{
|
||||
const id=(entry&&entry.id)||'';
|
||||
@@ -10035,23 +10118,23 @@ function _extensionSidecarCard(sidecars){
|
||||
</div>
|
||||
<div class="extension-sidecar-meta">${esc(meta)}</div>
|
||||
<div class="extension-sidecar-fields">
|
||||
<div><span>Origin</span><code>${esc(origin)}</code></div>
|
||||
<div><span>Health path</span><code>${esc(healthPath)}</code></div>
|
||||
<div><span>Health URL</span><code>${esc(healthUrl)}</code></div>
|
||||
<div><span>Proxy</span><code>${esc(proxyStatus)}</code></div>
|
||||
<div><span>Proxy path</span><code>${esc(proxyPath)}</code></div>
|
||||
<div><span>${esc(t('ext_sidecar_origin'))}</span><code>${esc(origin)}</code></div>
|
||||
<div><span>${esc(t('ext_sidecar_health_path'))}</span><code>${esc(healthPath)}</code></div>
|
||||
<div><span>${esc(t('ext_sidecar_health_url'))}</span><code>${esc(healthUrl)}</code></div>
|
||||
<div><span>${esc(t('ext_sidecar_proxy'))}</span><code>${esc(proxyStatus)}</code></div>
|
||||
<div><span>${esc(t('ext_sidecar_proxy_path'))}</span><code>${esc(proxyPath)}</code></div>
|
||||
</div>
|
||||
<div class="extension-sidecar-actions">${proxyButton}</div>
|
||||
${proxyWarning}
|
||||
<div class="extension-sidecar-runtime" data-sidecar-runtime-index="${index}" hidden></div>
|
||||
</div>`;
|
||||
}).join('')}</div>`:'<div class="extension-url-empty">No loopback sidecars declared.</div>';
|
||||
}).join('')}</div>`:'<div class="extension-url-empty">'+t('ext_sidecars_none')+'</div>';
|
||||
return `
|
||||
<div class="provider-card extension-sidecars-card">
|
||||
<div class="provider-card-header plugin-card-header">
|
||||
<div class="provider-card-info">
|
||||
<div class="provider-card-name">Loopback sidecars</div>
|
||||
<div class="provider-card-meta">Declared local companions; health is checked directly from this browser with WebUI credentials omitted.</div>
|
||||
<div class="provider-card-name">${esc(t('ext_sidecars_title'))}</div>
|
||||
<div class="provider-card-meta">${esc(t('ext_sidecars_meta'))}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-card-body extension-card-body">
|
||||
@@ -10144,35 +10227,35 @@ function _renderExtensionsPanel(data,seq){
|
||||
<div class="provider-card extension-status-card ${statusClass}">
|
||||
<div class="provider-card-header plugin-card-header">
|
||||
<div class="provider-card-info">
|
||||
<div class="provider-card-name">Extension runtime</div>
|
||||
<div class="provider-card-meta">Status from /api/extensions/status; toggles persist a local override for installed manifest entries.</div>
|
||||
<div class="provider-card-name">${esc(t('settings_extensions_runtime_title'))}</div>
|
||||
<div class="provider-card-meta">${esc(t('settings_extensions_runtime_status_from'))}</div>
|
||||
</div>
|
||||
<span class="provider-card-badge ${data&&data.enabled?'':'plugin-card-badge-disabled'}">${_extensionStatusLabel(!!(data&&data.enabled))}</span>
|
||||
</div>
|
||||
<div class="provider-card-body extension-card-body">
|
||||
<div class="extension-summary-grid">
|
||||
<div><span>Extension dir configured</span>${_extensionBooleanBadge(!!(data&&data.extension_dir_configured))}</div>
|
||||
<div><span>Extension dir valid</span>${_extensionBooleanBadge(!!(data&&data.extension_dir_valid))}</div>
|
||||
<div><span>Manifest configured</span>${_extensionBooleanBadge(!!manifest.configured)}</div>
|
||||
<div><span>Manifest loaded</span>${_extensionBooleanBadge(!!manifest.loaded)}</div>
|
||||
<div><span>Manifest status</span><code>${esc(manifest.status||'unknown')}</code></div>
|
||||
<div><span>Manifest entries inspected</span><code>${Number(manifest.entry_count)||0}</code></div>
|
||||
<div><span>Manifest script count</span><code>${Number(manifest.script_count)||0}</code></div>
|
||||
<div><span>Manifest stylesheet count</span><code>${Number(manifest.stylesheet_count)||0}</code></div>
|
||||
<div><span>Manifest sidecar count</span><code>${Number(manifest.sidecar_count)||0}</code></div>
|
||||
<div><span>Final script count</span><code>${scriptCount}</code></div>
|
||||
<div><span>Final stylesheet count</span><code>${styleCount}</code></div>
|
||||
<div><span>Loopback sidecar count</span><code>${sidecarCount}</code></div>
|
||||
<div><span>Installed manifest extensions</span><code>${manifestExtensionCount}</code></div>
|
||||
<div><span>User-disabled extensions</span><code>${userDisabledCount}</code></div>
|
||||
<div><span>${esc(t('settings_extensions_diag_extension_dir_configured'))}</span>${_extensionBooleanBadge(!!(data&&data.extension_dir_configured))}</div>
|
||||
<div><span>${esc(t('settings_extensions_diag_extension_dir_valid'))}</span>${_extensionBooleanBadge(!!(data&&data.extension_dir_valid))}</div>
|
||||
<div><span>${esc(t('settings_extensions_diag_manifest_configured'))}</span>${_extensionBooleanBadge(!!manifest.configured)}</div>
|
||||
<div><span>${esc(t('settings_extensions_diag_manifest_loaded'))}</span>${_extensionBooleanBadge(!!manifest.loaded)}</div>
|
||||
<div><span>${esc(t('settings_extensions_diag_manifest_status'))}</span><code>${esc(manifest.status||'unknown')}</code></div>
|
||||
<div><span>${esc(t('settings_extensions_diag_manifest_entries'))}</span><code>${Number(manifest.entry_count)||0}</code></div>
|
||||
<div><span>${esc(t('settings_extensions_diag_manifest_scripts'))}</span><code>${Number(manifest.script_count)||0}</code></div>
|
||||
<div><span>${esc(t('settings_extensions_diag_manifest_stylesheets'))}</span><code>${Number(manifest.stylesheet_count)||0}</code></div>
|
||||
<div><span>${esc(t('settings_extensions_diag_manifest_sidecars'))}</span><code>${Number(manifest.sidecar_count)||0}</code></div>
|
||||
<div><span>${esc(t('settings_extensions_diag_final_scripts'))}</span><code>${scriptCount}</code></div>
|
||||
<div><span>${esc(t('settings_extensions_diag_final_stylesheets'))}</span><code>${styleCount}</code></div>
|
||||
<div><span>${esc(t('settings_extensions_diag_loopback_sidecars'))}</span><code>${sidecarCount}</code></div>
|
||||
<div><span>${esc(t('settings_extensions_diag_installed_extensions'))}</span><code>${manifestExtensionCount}</code></div>
|
||||
<div><span>${esc(t('settings_extensions_diag_user_disabled'))}</span><code>${userDisabledCount}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-card extension-installed-card">
|
||||
<div class="provider-card-header plugin-card-header">
|
||||
<div class="provider-card-info">
|
||||
<div class="provider-card-name">Installed manifest extensions</div>
|
||||
<div class="provider-card-meta">Enable or disable already-present local extensions. Reload WebUI to apply injected asset changes to this browser tab.</div>
|
||||
<div class="provider-card-name">${esc(t('settings_extensions_installed_section_title'))}</div>
|
||||
<div class="provider-card-meta">${esc(t('settings_extensions_installed_section_meta'))}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-card-body extension-card-body">
|
||||
@@ -10182,14 +10265,14 @@ function _renderExtensionsPanel(data,seq){
|
||||
<div class="provider-card extension-assets-card">
|
||||
<div class="provider-card-header plugin-card-header">
|
||||
<div class="provider-card-info">
|
||||
<div class="provider-card-name">Final public asset URLs</div>
|
||||
<div class="provider-card-meta">Same-origin URLs that may be injected into the app shell.</div>
|
||||
<div class="provider-card-name">${esc(t('ext_assets_title'))}</div>
|
||||
<div class="provider-card-meta">${esc(t('ext_assets_meta'))}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-card-body extension-card-body">
|
||||
<div class="provider-card-label">Scripts</div>
|
||||
<div class="provider-card-label">${esc(t('ext_assets_scripts'))}</div>
|
||||
${_extensionAssetList(scripts)}
|
||||
<div class="provider-card-label extension-section-label">Stylesheets</div>
|
||||
<div class="provider-card-label extension-section-label">${esc(t('ext_assets_styles'))}</div>
|
||||
${_extensionAssetList(styles)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -10197,8 +10280,8 @@ function _renderExtensionsPanel(data,seq){
|
||||
<div class="provider-card extension-warnings-card">
|
||||
<div class="provider-card-header plugin-card-header">
|
||||
<div class="provider-card-info">
|
||||
<div class="provider-card-name">Sanitized warnings</div>
|
||||
<div class="provider-card-meta">Codes and coarse sources only; paths and rejected values are not shown.</div>
|
||||
<div class="provider-card-name">${esc(t('ext_warnings_title'))}</div>
|
||||
<div class="provider-card-meta">${esc(t('ext_warnings_meta'))}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-card-body extension-card-body">
|
||||
@@ -13156,7 +13239,12 @@ function loadMcpServers(){
|
||||
list.innerHTML=`<div class="mcp-empty-state" style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t('mcp_no_servers'))}</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML=r.servers.map(s=>{
|
||||
// Live status is withheld while the profile's runtime scope cannot be confirmed
|
||||
// (e.g. a chat turn on this profile is running); say so instead of "not connected".
|
||||
const scopeNotice=r.runtime_scope==='unavailable'
|
||||
?`<div class="mcp-runtime-notice" style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t('mcp_runtime_scope_unavailable'))}</div>`
|
||||
:'';
|
||||
list.innerHTML=scopeNotice+r.servers.map(s=>{
|
||||
const transportLabel=s.transport==='http'?'HTTP':s.transport==='stdio'?'stdio':(''+(s.transport||'unknown'));
|
||||
const transportClass=s.transport==='http'?'mcp-http':s.transport==='stdio'?'mcp-stdio':'mcp-unknown';
|
||||
const transportBadge=`<span class="mcp-transport-badge ${transportClass}">${esc(transportLabel)}</span>`;
|
||||
|
||||
+677
-34
@@ -346,7 +346,20 @@ function _clearComposerDraft(sid, text, files) {
|
||||
}
|
||||
|
||||
const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts';
|
||||
// A deleted session's acknowledgement must not return from a stale client's
|
||||
// cache. Membership in the rendered list cannot decide this: the sidebar filters
|
||||
// by profile, project, and source, so an absent row is not evidence of deletion.
|
||||
// Each deletion therefore records its own key, and merges drop any entry whose
|
||||
// session has one. Records are pruned by age so the store stays bounded.
|
||||
const SESSION_VIEWED_COUNTS_DELETED_PREFIX = 'hermes-session-viewed-counts:deleted:v1:';
|
||||
const SESSION_VIEWED_COUNTS_DELETED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread';
|
||||
// Clear ordering is stored per session so two clients clearing different
|
||||
// sessions never replace one another's tombstones with whole-map writes. The
|
||||
// unsuffixed key is the legacy map read during migration.
|
||||
const SESSION_COMPLETION_UNREAD_CLEARED_KEY = 'hermes-session-completion-unread-cleared';
|
||||
const SESSION_COMPLETION_UNREAD_CLEARED_PREFIX = `${SESSION_COMPLETION_UNREAD_CLEARED_KEY}:v1:`;
|
||||
const SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const SESSION_OBSERVED_STREAMING_KEY = 'hermes-session-observed-streaming';
|
||||
// Per-profile session-count cache (issue #4717 / #4662 Phase 1.5). Records how
|
||||
// many sessions each profile rendered last time, keyed by profile name, so a
|
||||
@@ -359,6 +372,7 @@ const SESSION_PROFILE_COUNTS_KEY = 'hermes-session-profile-counts';
|
||||
let _sessionProfileCounts = null;
|
||||
let _sessionViewedCounts = null;
|
||||
let _sessionCompletionUnread = null;
|
||||
let _sessionCompletionUnreadClearedMemory = {};
|
||||
let _sessionObservedStreaming = null;
|
||||
const _sessionStreamingById = new Map();
|
||||
const _sessionListSnapshotById = new Map();
|
||||
@@ -476,23 +490,216 @@ function _knownSessionProfileCount(profile) {
|
||||
return (typeof v === 'number' && Number.isFinite(v)) ? v : null;
|
||||
}
|
||||
|
||||
// Read a persisted sid -> value map. Missing or unreadable values read as an
|
||||
// empty map, matching the getters above.
|
||||
function _readStoredJsonMap(key) {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(key) || '{}');
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch (_){
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Does this session still exist as far as this client can tell? The sidebar list
|
||||
// is the authority (deletion drops the row) and the visit snapshot covers the
|
||||
// window before the first list render.
|
||||
function _sessionExistsForUnreadState(sid) {
|
||||
if (!sid) return false;
|
||||
if (typeof _allSessions !== 'undefined' && Array.isArray(_allSessions)
|
||||
&& _allSessions.some((s) => s && s.session_id === sid)) {
|
||||
return true;
|
||||
}
|
||||
return typeof _sessionListSnapshotById !== 'undefined' && _sessionListSnapshotById
|
||||
&& typeof _sessionListSnapshotById.has === 'function'
|
||||
&& _sessionListSnapshotById.has(sid);
|
||||
}
|
||||
|
||||
// Is there a session list to judge existence against at all? Without one,
|
||||
// "unknown" must not be read as "deleted": a client that has not rendered a list
|
||||
// yet would otherwise refuse to persist a fresh acknowledgement, and would prune
|
||||
// every tombstone it holds.
|
||||
function _sessionListLoaded() {
|
||||
if (typeof _allSessions !== 'undefined' && Array.isArray(_allSessions)
|
||||
&& _allSessions.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return typeof _sessionListSnapshotById !== 'undefined' && _sessionListSnapshotById
|
||||
&& typeof _sessionListSnapshotById.size === 'number'
|
||||
&& _sessionListSnapshotById.size > 0;
|
||||
}
|
||||
|
||||
// Viewed acknowledgements are generation-scoped. Transcript mutation routes
|
||||
// increment `transcript_generation` whenever the visible message list shrinks;
|
||||
// counts are monotonic only within one generation. Legacy numeric entries belong
|
||||
// to generation zero and are migrated on their next save.
|
||||
function _sessionViewedCountRecord(value, transcriptGeneration = 0) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return {
|
||||
message_count: Number(value.message_count) || 0,
|
||||
transcript_generation: Math.max(0, Number(value.transcript_generation) || 0),
|
||||
};
|
||||
}
|
||||
return {
|
||||
message_count: Number(value) || 0,
|
||||
transcript_generation: Math.max(0, Number(transcriptGeneration) || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function _sessionViewedCountValue(value) {
|
||||
return _sessionViewedCountRecord(value).message_count;
|
||||
}
|
||||
|
||||
function _sessionViewedRecordWins(candidate, current) {
|
||||
const ours = _sessionViewedCountRecord(candidate);
|
||||
const theirs = _sessionViewedCountRecord(current);
|
||||
if (ours.transcript_generation !== theirs.transcript_generation) {
|
||||
return ours.transcript_generation > theirs.transcript_generation;
|
||||
}
|
||||
return ours.message_count > theirs.message_count;
|
||||
}
|
||||
|
||||
function _sessionTranscriptGenerationForUnread(sid, explicit = null) {
|
||||
if (explicit !== null && explicit !== undefined && Number.isFinite(Number(explicit))) {
|
||||
return Math.max(0, Number(explicit) || 0);
|
||||
}
|
||||
let active = null;
|
||||
try {
|
||||
active = (S && S.session && S.session.session_id === sid) ? S.session : null;
|
||||
} catch (_) {}
|
||||
const snapshot = (typeof _sessionListSnapshotById !== 'undefined' && _sessionListSnapshotById)
|
||||
? _sessionListSnapshotById.get(sid) : null;
|
||||
const listed = (typeof _allSessions !== 'undefined' && Array.isArray(_allSessions))
|
||||
? _allSessions.find((s) => s && s.session_id === sid) : null;
|
||||
const session = active || snapshot || listed;
|
||||
return Math.max(0, Number(session && session.transcript_generation) || 0);
|
||||
}
|
||||
|
||||
function _mergeSessionViewedCounts(disk, cache) {
|
||||
const deleted = _readSessionViewedCountDeletions();
|
||||
const gone = (sid) => Object.prototype.hasOwnProperty.call(deleted, sid);
|
||||
const counts = {};
|
||||
let changed = false;
|
||||
for (const sid of Object.keys(disk || {})) {
|
||||
if (gone(sid)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
const record = _sessionViewedCountRecord(disk[sid]);
|
||||
counts[sid] = record;
|
||||
if (!disk[sid] || typeof disk[sid] !== 'object' || Array.isArray(disk[sid])) changed = true;
|
||||
}
|
||||
for (const sid of Object.keys(cache || {})) {
|
||||
if (gone(sid)) continue;
|
||||
const ours = _sessionViewedCountRecord(cache[sid]);
|
||||
if (Object.prototype.hasOwnProperty.call(counts, sid)) {
|
||||
if (_sessionViewedRecordWins(ours, counts[sid])) {
|
||||
counts[sid] = ours;
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (_sessionListLoaded() && !_sessionExistsForUnreadState(sid)) continue;
|
||||
counts[sid] = ours;
|
||||
changed = true;
|
||||
}
|
||||
const live = {};
|
||||
for (const sid of Object.keys(counts)) live[sid] = counts[sid];
|
||||
for (const sid of Object.keys(cache || {})) {
|
||||
if (gone(sid)) continue;
|
||||
const ours = _sessionViewedCountRecord(cache[sid]);
|
||||
if (!Object.prototype.hasOwnProperty.call(live, sid) || _sessionViewedRecordWins(ours, live[sid])) {
|
||||
live[sid] = ours;
|
||||
}
|
||||
}
|
||||
return {counts, live, changed};
|
||||
}
|
||||
|
||||
function _sessionViewedCountDeletedKey(sid) {
|
||||
return `${SESSION_VIEWED_COUNTS_DELETED_PREFIX}${encodeURIComponent(String(sid))}`;
|
||||
}
|
||||
|
||||
// Read the deletion records, dropping any that have aged out.
|
||||
function _readSessionViewedCountDeletions() {
|
||||
const deleted = {};
|
||||
let keys = [];
|
||||
try {
|
||||
keys = Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i));
|
||||
} catch (_) {
|
||||
return deleted;
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const key of keys) {
|
||||
if (typeof key !== 'string' || !key.startsWith(SESSION_VIEWED_COUNTS_DELETED_PREFIX)) continue;
|
||||
const encoded = key.slice(SESSION_VIEWED_COUNTS_DELETED_PREFIX.length);
|
||||
let sid = '';
|
||||
try {
|
||||
sid = decodeURIComponent(encoded);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
if (!sid) continue;
|
||||
let stamp = 0;
|
||||
try {
|
||||
stamp = Number(localStorage.getItem(key)) || 0;
|
||||
} catch (_) {}
|
||||
if (stamp && now - stamp > SESSION_VIEWED_COUNTS_DELETED_TTL_MS) {
|
||||
try { localStorage.removeItem(key); } catch (_) {}
|
||||
continue;
|
||||
}
|
||||
deleted[sid] = stamp;
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
function _recordSessionViewedCountDeleted(sid) {
|
||||
if (!sid) return;
|
||||
try {
|
||||
localStorage.setItem(_sessionViewedCountDeletedKey(sid), String(Date.now()));
|
||||
} catch (_) {
|
||||
// Without the record the deletion still removed the entry; the next delete
|
||||
// or merge attempt records it again.
|
||||
}
|
||||
}
|
||||
|
||||
function _saveSessionViewedCounts() {
|
||||
try {
|
||||
localStorage.setItem(SESSION_VIEWED_COUNTS_KEY, JSON.stringify(_getSessionViewedCounts()));
|
||||
const merged = _mergeSessionViewedCounts(
|
||||
_readStoredJsonMap(SESSION_VIEWED_COUNTS_KEY),
|
||||
_getSessionViewedCounts()
|
||||
);
|
||||
// Write only when we hold something the store does not (a strictly greater
|
||||
// count, or an entry it is missing). Rewriting an unchanged map would let two
|
||||
// clients ping-pong storage events forever.
|
||||
if (merged.changed) {
|
||||
localStorage.setItem(SESSION_VIEWED_COUNTS_KEY, JSON.stringify(merged.counts));
|
||||
}
|
||||
_sessionViewedCounts = merged.live;
|
||||
} catch (_){
|
||||
// Ignore localStorage write failures.
|
||||
}
|
||||
}
|
||||
|
||||
function _setSessionViewedCount(sid, messageCount = 0) {
|
||||
function _setSessionViewedCount(sid, messageCount = 0, transcriptGeneration = null) {
|
||||
if (!sid) return;
|
||||
const counts = _getSessionViewedCounts();
|
||||
const next = Number.isFinite(messageCount) ? Number(messageCount) : 0;
|
||||
counts[sid] = next;
|
||||
const generation = _sessionTranscriptGenerationForUnread(sid, transcriptGeneration);
|
||||
const incoming = _sessionViewedCountRecord(next, generation);
|
||||
const hadCount = Object.prototype.hasOwnProperty.call(counts, sid);
|
||||
const previous = hadCount ? _sessionViewedCountRecord(counts[sid]) : null;
|
||||
if (!previous || _sessionViewedRecordWins(incoming, previous)) {
|
||||
counts[sid] = incoming;
|
||||
} else {
|
||||
counts[sid] = previous;
|
||||
}
|
||||
_saveSessionViewedCounts();
|
||||
// If the viewed count is now current, any prior completion-unread marker is
|
||||
// stale — clear it so _hasUnreadForSession doesn't short-circuit (#3020).
|
||||
_clearSessionCompletionUnread(sid);
|
||||
// A generation change or count advance acknowledges completion state even if
|
||||
// another client prepared an older marker before this save.
|
||||
const advanced = !previous
|
||||
|| generation > previous.transcript_generation
|
||||
|| (generation === previous.transcript_generation && next > previous.message_count);
|
||||
_clearSessionCompletionUnread(sid, advanced);
|
||||
}
|
||||
|
||||
function _getSessionCompletionUnread() {
|
||||
@@ -506,9 +713,140 @@ function _getSessionCompletionUnread() {
|
||||
return _sessionCompletionUnread;
|
||||
}
|
||||
|
||||
// Completion-unread markers are add/remove, so unlike viewed counts they cannot
|
||||
// be max-merged: the later event has to win. Every clear records a tombstone
|
||||
// (sid -> clearedAt) in SESSION_COMPLETION_UNREAD_CLEARED_KEY, and a marker only
|
||||
// survives when it does not predate its sid's clear.
|
||||
function _markerLosesToUnreadClear(marker, clearedAt) {
|
||||
const cleared = Number(clearedAt) || 0;
|
||||
if (!cleared) return false;
|
||||
return _sessionCompletionUnreadOrder(marker) <= cleared;
|
||||
}
|
||||
|
||||
function _sessionCompletionUnreadOrder(marker) {
|
||||
return Number(marker && (marker.unread_order || marker.completed_at)) || 0;
|
||||
}
|
||||
|
||||
function _sessionCompletionUnreadClearedKey(sid, clearedAt = null) {
|
||||
const base = `${SESSION_COMPLETION_UNREAD_CLEARED_PREFIX}${encodeURIComponent(String(sid))}`;
|
||||
return clearedAt == null ? base : `${base}:${Number(clearedAt) || 0}`;
|
||||
}
|
||||
|
||||
function _parseSessionCompletionUnreadClearedKey(key) {
|
||||
if (!key || !key.startsWith(SESSION_COMPLETION_UNREAD_CLEARED_PREFIX)) return null;
|
||||
const suffix = key.slice(SESSION_COMPLETION_UNREAD_CLEARED_PREFIX.length);
|
||||
const separator = suffix.lastIndexOf(':');
|
||||
const encodedSid = separator > 0 ? suffix.slice(0, separator) : suffix;
|
||||
const version = separator > 0 ? Number(suffix.slice(separator + 1)) || 0 : 0;
|
||||
let sid = '';
|
||||
try { sid = decodeURIComponent(encodedSid); } catch (_) { return null; }
|
||||
if (!sid) return null;
|
||||
return { sid, version };
|
||||
}
|
||||
|
||||
// Read persisted clear ordering independently from the in-memory fallback. The
|
||||
// distinction matters during pruning: a higher clear that failed quota and lives
|
||||
// only in memory must not delete the older durable record it supersedes.
|
||||
function _readPersistedSessionCompletionUnreadCleared() {
|
||||
// Snapshot keys before values. A clear that starts after this operation began
|
||||
// is deliberately excluded, so a marker prepared first gets the same logical
|
||||
// order and loses the clear-wins tie. A marker started after a completed clear
|
||||
// observes that clear and advances beyond it.
|
||||
let keys = [];
|
||||
try {
|
||||
keys = Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i));
|
||||
} catch (_) {}
|
||||
const cleared = _readStoredJsonMap(SESSION_COMPLETION_UNREAD_CLEARED_KEY);
|
||||
try {
|
||||
for (const key of keys) {
|
||||
const parsed = _parseSessionCompletionUnreadClearedKey(key);
|
||||
if (!parsed) continue;
|
||||
const storedValue = Number(localStorage.getItem(key)) || 0;
|
||||
const value = parsed.version || storedValue;
|
||||
if (value > (Number(cleared[parsed.sid]) || 0)) cleared[parsed.sid] = value;
|
||||
}
|
||||
} catch (_) {
|
||||
// A storage implementation that cannot enumerate still retains legacy data.
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
|
||||
// Combine durable clear ordering with this client's in-memory fallback. The
|
||||
// fallback keeps the current window correct when quota rejects a new key.
|
||||
function _readSessionCompletionUnreadCleared() {
|
||||
const cleared = _readPersistedSessionCompletionUnreadCleared();
|
||||
const now = Date.now();
|
||||
for (const [sid, memoryRecord] of Object.entries(_sessionCompletionUnreadClearedMemory || {})) {
|
||||
const value = Number(memoryRecord && memoryRecord.order) || 0;
|
||||
const recordedAt = Number(memoryRecord && memoryRecord.recorded_at) || 0;
|
||||
if (recordedAt && now - recordedAt > SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS) {
|
||||
delete _sessionCompletionUnreadClearedMemory[sid];
|
||||
continue;
|
||||
}
|
||||
if (value > (Number(cleared[sid]) || 0)) cleared[sid] = value;
|
||||
}
|
||||
return cleared;
|
||||
}
|
||||
|
||||
function _nextSessionCompletionUnreadOrder(sid, cleared = null) {
|
||||
const clears = cleared || _readSessionCompletionUnreadCleared();
|
||||
const cached = _getSessionCompletionUnread()[sid];
|
||||
const stored = _readStoredJsonMap(SESSION_COMPLETION_UNREAD_KEY)[sid];
|
||||
return Math.max(
|
||||
Date.now(),
|
||||
Number(clears[sid]) || 0,
|
||||
_sessionCompletionUnreadOrder(cached),
|
||||
_sessionCompletionUnreadOrder(stored)
|
||||
) + 1;
|
||||
}
|
||||
|
||||
// Merge the persisted marker map with our cache under the tombstones. Markers
|
||||
// the store holds that a clear already outranks are dropped from the result, so
|
||||
// the caller can write the repaired map back and no reader sees them again.
|
||||
function _mergeSessionCompletionUnread(disk, cache, cleared) {
|
||||
const markers = {};
|
||||
let changed = false;
|
||||
for (const sid of Object.keys(disk || {})) {
|
||||
const stored = disk[sid];
|
||||
if (_markerLosesToUnreadClear(stored, (cleared || {})[sid])) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
markers[sid] = stored;
|
||||
}
|
||||
for (const sid of Object.keys(cache || {})) {
|
||||
const ours = cache[sid];
|
||||
if (_markerLosesToUnreadClear(ours, (cleared || {})[sid])) continue;
|
||||
if (Object.prototype.hasOwnProperty.call(markers, sid)) {
|
||||
// Both sides hold a marker: the later logical operation wins, which is the
|
||||
// same order the clear comparison uses. Comparing completed_at here would
|
||||
// keep the earlier of two markers written in one millisecond and leave that
|
||||
// one's message count or cron profile metadata for later cleanup.
|
||||
if (_sessionCompletionUnreadOrder(ours) > _sessionCompletionUnreadOrder(markers[sid])) {
|
||||
markers[sid] = ours;
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
markers[sid] = ours;
|
||||
changed = true;
|
||||
}
|
||||
return {markers, changed};
|
||||
}
|
||||
|
||||
function _saveSessionCompletionUnread() {
|
||||
try {
|
||||
localStorage.setItem(SESSION_COMPLETION_UNREAD_KEY, JSON.stringify(_getSessionCompletionUnread()));
|
||||
const merged = _mergeSessionCompletionUnread(
|
||||
_readStoredJsonMap(SESSION_COMPLETION_UNREAD_KEY),
|
||||
_getSessionCompletionUnread(),
|
||||
_readSessionCompletionUnreadCleared()
|
||||
);
|
||||
// Same convergence rule as the viewed counts: write only when the store does
|
||||
// not already hold the merged result.
|
||||
if (merged.changed) {
|
||||
localStorage.setItem(SESSION_COMPLETION_UNREAD_KEY, JSON.stringify(merged.markers));
|
||||
}
|
||||
_sessionCompletionUnread = merged.markers;
|
||||
} catch (_){
|
||||
// Ignore localStorage write failures.
|
||||
}
|
||||
@@ -518,7 +856,12 @@ function _markSessionCompletionUnread(sid, messageCount = 0, meta = null) {
|
||||
if (!sid) return;
|
||||
const unread = _getSessionCompletionUnread();
|
||||
const count = Number.isFinite(messageCount) ? Number(messageCount) : 0;
|
||||
const entry = {message_count: count, completed_at: Date.now()};
|
||||
const clearsAtIntent = _readSessionCompletionUnreadCleared();
|
||||
const entry = {
|
||||
message_count: count,
|
||||
completed_at: Date.now(),
|
||||
unread_order: _nextSessionCompletionUnreadOrder(sid, clearsAtIntent),
|
||||
};
|
||||
// Cron markers carry source+profile so profile switches can clear only that
|
||||
// cross-profile leak without wiping ordinary chat completion unread (#5960).
|
||||
if (meta && typeof meta === 'object' && !Array.isArray(meta)) {
|
||||
@@ -550,11 +893,88 @@ function _markSessionCompletionUnreadIfBackground(sid, messageCount = null, meta
|
||||
return true;
|
||||
}
|
||||
|
||||
function _clearSessionCompletionUnread(sid) {
|
||||
// Record that the completion-unread marker(s) for `sidOrSids` were cleared at
|
||||
// `clearedAt`. Each session owns a separate key: clears for different sessions
|
||||
// are independent atomic writes and cannot discard one another. Markers carry a
|
||||
// completed_at timestamp, so a marker that does not postdate the clear loses,
|
||||
// while a completion that happened afterwards still wins. Old or deleted-session
|
||||
// tombstones are pruned opportunistically to bound quota use.
|
||||
function _writeSessionCompletionUnreadCleared(sidOrSids, clearedAt) {
|
||||
const justCleared = new Set(
|
||||
(Array.isArray(sidOrSids) ? sidOrSids : [sidOrSids]).map((sid) => String(sid))
|
||||
);
|
||||
const at = Number(clearedAt) || 0;
|
||||
for (const sid of justCleared) {
|
||||
const previous = _sessionCompletionUnreadClearedMemory[sid];
|
||||
if (at > (Number(previous && previous.order) || 0)) {
|
||||
_sessionCompletionUnreadClearedMemory[sid] = {order: at, recorded_at: Date.now()};
|
||||
}
|
||||
}
|
||||
|
||||
// One-way migration. The previous representation kept every clear fact in one
|
||||
// shared blob, so concurrent clears could replace one another and the blob
|
||||
// only ever grew. Import those facts as independently addressable records and
|
||||
// drop the blob. A client still running the previous revision will not observe
|
||||
// clears recorded after this point; that is a reload boundary, not something a
|
||||
// dual write could have fixed, because the shared blob cannot be written
|
||||
// concurrently without losing an update.
|
||||
const legacy = _readStoredJsonMap(SESSION_COMPLETION_UNREAD_CLEARED_KEY);
|
||||
let migrated = true;
|
||||
for (const [sid, value] of Object.entries(legacy)) {
|
||||
const stamp = Number(value) || 0;
|
||||
if (!stamp) continue;
|
||||
try {
|
||||
localStorage.setItem(_sessionCompletionUnreadClearedKey(sid, stamp), String(Date.now()));
|
||||
} catch (_) {
|
||||
migrated = false;
|
||||
}
|
||||
}
|
||||
for (const sid of justCleared) {
|
||||
try {
|
||||
localStorage.setItem(_sessionCompletionUnreadClearedKey(sid, at), String(Date.now()));
|
||||
} catch (_) {}
|
||||
}
|
||||
if (Object.keys(legacy).length && migrated) {
|
||||
try { localStorage.removeItem(SESSION_COMPLETION_UNREAD_CLEARED_KEY); } catch (_) {}
|
||||
}
|
||||
|
||||
const persistedCleared = _readPersistedSessionCompletionUnreadCleared();
|
||||
let keys = [];
|
||||
try {
|
||||
keys = Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i));
|
||||
} catch (_) {}
|
||||
for (const key of keys) {
|
||||
const parsed = _parseSessionCompletionUnreadClearedKey(key);
|
||||
if (!parsed) continue;
|
||||
try {
|
||||
const recordedAt = Number(localStorage.getItem(key)) || 0;
|
||||
const version = parsed.version || recordedAt;
|
||||
const expired = recordedAt > 0 && Date.now() - recordedAt > SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS;
|
||||
const superseded = version < (Number(persistedCleared[parsed.sid]) || 0);
|
||||
// Prune by age, or when a newer ordering fact is itself durable. A
|
||||
// memory-only clear may keep this window correct under quota pressure, but
|
||||
// it cannot safely replace the older record across reload.
|
||||
if (expired || superseded) localStorage.removeItem(key);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function _clearSessionCompletionUnread(sid, recordIfMissing = true) {
|
||||
if (!sid) return;
|
||||
const unread = _getSessionCompletionUnread();
|
||||
if (!Object.prototype.hasOwnProperty.call(unread, sid)) return;
|
||||
let stored = false;
|
||||
if (!Object.prototype.hasOwnProperty.call(unread, sid)) {
|
||||
// Another client may have recorded this marker since our cache was loaded,
|
||||
// so the store decides whether there is anything to clear.
|
||||
stored = Object.prototype.hasOwnProperty.call(
|
||||
_readStoredJsonMap(SESSION_COMPLETION_UNREAD_KEY), sid);
|
||||
if (!stored && !recordIfMissing) return;
|
||||
}
|
||||
const order = _nextSessionCompletionUnreadOrder(sid);
|
||||
delete unread[sid];
|
||||
// Write the ordering fact before the marker map. This is required even when
|
||||
// the marker has been prepared by another client but has not reached storage.
|
||||
_writeSessionCompletionUnreadCleared(sid, order);
|
||||
_saveSessionCompletionUnread();
|
||||
}
|
||||
|
||||
@@ -666,6 +1086,7 @@ function _clearCronSessionCompletionUnreadForInactiveProfiles(activeProfile) {
|
||||
: 'default';
|
||||
const unread = _getSessionCompletionUnread();
|
||||
let changed = false;
|
||||
const clearedSids = [];
|
||||
for (const sid of Object.keys(unread)) {
|
||||
const marker = unread[sid];
|
||||
if (!marker || typeof marker !== 'object' || Array.isArray(marker)) continue;
|
||||
@@ -676,9 +1097,16 @@ function _clearCronSessionCompletionUnreadForInactiveProfiles(activeProfile) {
|
||||
if (!resolved.profile) continue;
|
||||
if (_cronMarkerProfileMatchesActive(resolved.profile, active)) continue;
|
||||
delete unread[sid];
|
||||
clearedSids.push(sid);
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return false;
|
||||
// Each cleared marker needs its durable ordering fact, or the merge in
|
||||
// _saveSessionCompletionUnread() (and a stale client's later save) would
|
||||
// restore it from the store.
|
||||
for (const sid of clearedSids) {
|
||||
_writeSessionCompletionUnreadCleared(sid, _nextSessionCompletionUnreadOrder(sid));
|
||||
}
|
||||
_saveSessionCompletionUnread();
|
||||
if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache();
|
||||
return true;
|
||||
@@ -687,9 +1115,34 @@ function _clearCronSessionCompletionUnreadForInactiveProfiles(activeProfile) {
|
||||
function _clearSessionViewedCount(sid) {
|
||||
if (!sid) return;
|
||||
const counts = _getSessionViewedCounts();
|
||||
if (!Object.prototype.hasOwnProperty.call(counts, sid)) return;
|
||||
const hadCachedEntry = Object.prototype.hasOwnProperty.call(counts, sid);
|
||||
delete counts[sid];
|
||||
_saveSessionViewedCounts();
|
||||
// Record the deletion before any merge, so this client cannot re-adopt the
|
||||
// entry it is deleting and another client holding the same acknowledgement
|
||||
// prunes it instead of writing it back.
|
||||
_recordSessionViewedCountDeleted(sid);
|
||||
// A removal must actually leave disk, unlike the additive max-merge used on
|
||||
// the set path (which never deletes). Delete just this key from the persisted
|
||||
// map so entries a concurrent client added are preserved. The store is
|
||||
// consulted even when our cache never held the key: an entry another client
|
||||
// added since our cache was loaded is exactly the one this path has to prune.
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(SESSION_VIEWED_COUNTS_KEY) || '{}');
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
if (Object.prototype.hasOwnProperty.call(parsed, sid)) {
|
||||
delete parsed[sid];
|
||||
localStorage.setItem(SESSION_VIEWED_COUNTS_KEY, JSON.stringify(parsed));
|
||||
}
|
||||
// Adopt the store's entries but keep any higher count we already hold: we
|
||||
// are the only client left that can repair a count another client
|
||||
// lowered, so dropping ours here would lose the acknowledgement.
|
||||
_sessionViewedCounts = _mergeSessionViewedCounts(parsed, counts).live;
|
||||
return;
|
||||
}
|
||||
} catch (_){
|
||||
// Fall through to the cache-only write on read/parse failure.
|
||||
}
|
||||
if (hadCachedEntry) _saveSessionViewedCounts();
|
||||
}
|
||||
|
||||
function _hasSessionCompletionUnread(sid) {
|
||||
@@ -739,12 +1192,22 @@ function _hasUnreadForSession(s) {
|
||||
if (!s || !s.session_id) return false;
|
||||
if (_hasSessionCompletionUnread(s.session_id)) return true;
|
||||
const counts = _getSessionViewedCounts();
|
||||
const generation = _sessionTranscriptGenerationForUnread(s.session_id, s.transcript_generation);
|
||||
if (!Object.prototype.hasOwnProperty.call(counts, s.session_id)) {
|
||||
_setSessionViewedCount(s.session_id, Number(s.message_count || 0));
|
||||
_setSessionViewedCount(s.session_id, Number(s.message_count || 0), generation);
|
||||
return false;
|
||||
}
|
||||
if (!Number.isFinite(s.message_count)) return false;
|
||||
return s.message_count > Number(counts[s.session_id] || 0);
|
||||
const viewed = _sessionViewedCountRecord(counts[s.session_id]);
|
||||
if (generation > viewed.transcript_generation) {
|
||||
const baseline = Math.max(0, Math.min(
|
||||
Number(s.message_count || 0),
|
||||
Number(s.transcript_generation_baseline) || 0
|
||||
));
|
||||
_setSessionViewedCount(s.session_id, baseline, generation);
|
||||
return Number.isFinite(s.message_count) && s.message_count > baseline;
|
||||
}
|
||||
if (generation < viewed.transcript_generation || !Number.isFinite(s.message_count)) return false;
|
||||
return s.message_count > viewed.message_count;
|
||||
}
|
||||
|
||||
// Keep the sidebar polling snapshot current for a just-visited session so a
|
||||
@@ -852,6 +1315,14 @@ function _reconcileActiveSessionIdleStateFromList(serverRows) {
|
||||
const serverRow=serverRows.find(s=>s&&s.session_id===sid);
|
||||
if (!serverRow) return false;
|
||||
if (!_isServerIdleSessionRow(serverRow)) return false;
|
||||
// Sidebar idle metadata can beat the terminal frame on the independent chat
|
||||
// SSE. Let its exact OPEN transport finish the Anchor handoff; orphaned or
|
||||
// disconnected streams still use the existing idle recovery below.
|
||||
if (_hasOwnedOpenLiveStream(sid)) {
|
||||
const live=LIVE_STREAMS[sid];
|
||||
if(typeof live.recoverFromSidebarIdle==='function') live.recoverFromSidebarIdle();
|
||||
return false;
|
||||
}
|
||||
let changed=false;
|
||||
if (S.busy) { S.busy=false; changed=true; }
|
||||
if (S.activeStreamId) { S.activeStreamId=null; changed=true; }
|
||||
@@ -940,6 +1411,8 @@ function _purgeStaleInflightEntries() {
|
||||
if (typeof _sendInProgress !== 'undefined' && _sendInProgress && sid === _sendInProgressSid) {
|
||||
continue;
|
||||
}
|
||||
// The sidebar render must not purge what the idle reconciler preserved.
|
||||
if (_hasOwnedOpenLiveStream(sid)) continue;
|
||||
if (!sessionsById.has(sid)) {
|
||||
const knownSource = sourceById ? sourceById.get(sid) : null;
|
||||
if (currentSidebarSource && (!knownSource || knownSource !== currentSidebarSource)) {
|
||||
@@ -961,6 +1434,14 @@ function _purgeStaleInflightEntries() {
|
||||
}
|
||||
}
|
||||
|
||||
function _hasOwnedOpenLiveStream(sid) {
|
||||
if (typeof S === 'undefined' || !S || !S.session || S.session.session_id !== sid || !S.activeStreamId) return false;
|
||||
const live = typeof LIVE_STREAMS === 'object' && LIVE_STREAMS ? LIVE_STREAMS[sid] : null;
|
||||
// readyState 1 is EventSource.OPEN. A cached stream ID or busy flag alone
|
||||
// is not ownership and must never disable recovery for a stuck indicator.
|
||||
return Boolean(live && live.streamId === S.activeStreamId && live.source && live.source.readyState === 1);
|
||||
}
|
||||
|
||||
function _rememberSessionListSource(s, sid = null, allowScopeFallback = true) {
|
||||
const resolvedSid = sid || (s && s.session_id);
|
||||
if (!resolvedSid) return;
|
||||
@@ -1017,6 +1498,74 @@ function _inflightHasVisibleLiveState(inflight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// #7640: a run-journal replay cursor is only safe when the recovery object can
|
||||
// still repaint the live assistant turn. `_inflightHasVisibleLiveState()` above
|
||||
// deliberately counts a plain user row (the optimistic row has to survive a
|
||||
// mid-turn reload), but such an object carries no assistant projection at all:
|
||||
// seeding `after_seq` from it tells the server to skip every journal event that
|
||||
// would rebuild the body, so the settled footer lands over a blank message until
|
||||
// a manual reload replays the journal from the zero floor. Only recoverable live
|
||||
// output may raise the replay floor.
|
||||
function _inflightCanSeedJournalReplay(inflight){
|
||||
if(!inflight||typeof inflight!=='object') return false;
|
||||
if(String(inflight.lastAssistantText||'').trim()) return true;
|
||||
if(String(inflight.lastReasoningText||'').trim()) return true;
|
||||
if(String(inflight.liveTurnHtml||'').trim()) return true;
|
||||
if(Array.isArray(inflight.toolCalls)&&inflight.toolCalls.length) return true;
|
||||
if(Array.isArray(inflight.activityBurstAnchors)&&inflight.activityBurstAnchors.length) return true;
|
||||
const anchorScene=inflight.anchorActivityScene;
|
||||
if(anchorScene&&Array.isArray(anchorScene.activity_rows)&&anchorScene.activity_rows.length) return true;
|
||||
if(Array.isArray(inflight.messages)){
|
||||
// The transcript copy carries every earlier turn (#7651): in an established
|
||||
// conversation the last assistant row is the PREVIOUS turn's reply, not the
|
||||
// one the cursor belongs to, so it must never authorize a nonzero floor.
|
||||
// Message evidence counts only from a current `_live` assistant standing
|
||||
// after the latest user boundary; anything historical fails closed to zero.
|
||||
const list=inflight.messages;
|
||||
let latestUserIdx=-1;
|
||||
for(let i=list.length-1;i>=0;i--){
|
||||
const row=list[i];
|
||||
if(row&&row.role==='user'){latestUserIdx=i;break;}
|
||||
}
|
||||
return list.some((msg,idx)=>{
|
||||
if(!msg||msg.role!=='assistant') return false;
|
||||
if(!msg._live) return false;
|
||||
if(idx<=latestUserIdx) return false;
|
||||
const content=msg.content;
|
||||
if(typeof content==='string') return Boolean(content.trim());
|
||||
if(Array.isArray(content)) return content.length>0;
|
||||
return Boolean(content);
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function _runJournalReplayFloorForInflight(inflight){
|
||||
if(!_inflightCanSeedJournalReplay(inflight)) return 0;
|
||||
return Math.max(0,Number((inflight&&inflight.lastRunJournalSeq)||0)||0);
|
||||
}
|
||||
|
||||
function _runJournalReplayEventIdForInflight(inflight){
|
||||
if(!_runJournalReplayFloorForInflight(inflight)) return '';
|
||||
return String((inflight&&inflight.lastRunJournalEventId)||'');
|
||||
}
|
||||
|
||||
// #7640: re-validate the selected recovery object immediately before reattach
|
||||
// instead of trusting its existence. A cursor that outlived its assistant
|
||||
// projection is dropped here so it cannot reach the wire as a replay floor; the
|
||||
// replay then restarts from zero, which is the path a manual reload already
|
||||
// proves out. Normalising the object (not just the floor) also keeps a later
|
||||
// persist from resurrecting the stale cursor on the next reattach.
|
||||
function _normalizeInflightReplayCursorForReattach(inflight){
|
||||
if(!inflight||typeof inflight!=='object') return inflight;
|
||||
const seq=Math.max(0,Number(inflight.lastRunJournalSeq||0)||0);
|
||||
if(seq>0&&!_inflightCanSeedJournalReplay(inflight)){
|
||||
inflight.lastRunJournalSeq=0;
|
||||
inflight.lastRunJournalEventId='';
|
||||
}
|
||||
return inflight;
|
||||
}
|
||||
|
||||
function _serverLiveSnapshotToolId(tc){
|
||||
return String(tc&&(tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||'')||'').trim();
|
||||
}
|
||||
@@ -1660,6 +2209,10 @@ async function _switchProfileForSessionLoad(profile){
|
||||
if(typeof _resetCronUnreadForProfileSwitch==='function'){
|
||||
_resetCronUnreadForProfileSwitch();
|
||||
}
|
||||
// #7509: mirror the canonical switch in panels.js — the slash-skill caches still
|
||||
// hold the previous profile's /api/skills payload, so drop them (and any reply
|
||||
// still in flight) once the switch has succeeded.
|
||||
if(typeof window!=='undefined'&&typeof window.invalidateSlashSkillCaches==='function') window.invalidateSlashSkillCaches();
|
||||
if(typeof _clearPersistedModelState==='function') _clearPersistedModelState();
|
||||
else localStorage.removeItem('hermes-webui-model');
|
||||
if(data.default_model) window._defaultModel=data.default_model;
|
||||
@@ -2165,6 +2718,12 @@ async function loadSession(sid){
|
||||
S.activeStreamId=activeStreamId;
|
||||
const liveToolReplayId=(tc)=>String(tc&&(tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||'')||'').trim();
|
||||
const replayPersistedLiveToolCards=(opts)=>{
|
||||
// The journal-backed Anchor scene is authoritative through its resume
|
||||
// cursor; newer rows arrive through the reattached SSE stream. Replaying
|
||||
// the older INFLIGHT tool cache after a successful scene restore would
|
||||
// redraw all N rows N times. Keep the #3707 replay only for legacy HTML
|
||||
// restoration or a failed/unavailable scene render.
|
||||
if(restoredAnchorScene) return;
|
||||
const liveToolCalls=Array.isArray(S.toolCalls)
|
||||
? S.toolCalls
|
||||
: (Array.isArray(INFLIGHT[sid]&&INFLIGHT[sid].toolCalls)?INFLIGHT[sid].toolCalls:[]);
|
||||
@@ -2180,6 +2739,11 @@ async function loadSession(sid){
|
||||
if(INFLIGHT[sid].reattach&&activeStreamId&&typeof attachLiveStream==='function'){
|
||||
INFLIGHT[sid].reattach=false;
|
||||
if (!_isCurrentLoad()) return;
|
||||
// #7640: validate the selected recovery object at the moment of reattach
|
||||
// rather than trusting its existence. A cache that kept `lastRunJournalSeq`
|
||||
// but lost the live assistant projection would otherwise seed the replay
|
||||
// floor and skip the journal range that rebuilds the message body.
|
||||
_normalizeInflightReplayCursorForReattach(INFLIGHT[sid]);
|
||||
didReconnect=true;
|
||||
attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
|
||||
}
|
||||
@@ -2309,6 +2873,8 @@ async function loadSession(sid){
|
||||
if(activeStreamId){
|
||||
S.busy=true;
|
||||
S.activeStreamId=activeStreamId;
|
||||
// #7640: same reattach validation as the `reattach` branch above.
|
||||
if(INFLIGHT[sid]) _normalizeInflightReplayCursorForReattach(INFLIGHT[sid]);
|
||||
if(typeof attachLiveStream==='function') attachLiveStream(sid, activeStreamId, S.session.pending_attachments||[], {reconnecting:true});
|
||||
else if(typeof watchInflightSession==='function') watchInflightSession(sid, activeStreamId);
|
||||
updateSendBtn();
|
||||
@@ -2424,7 +2990,7 @@ const _HANDOFF_THRESHOLD = 10; // conversation rounds
|
||||
const _HANDOFF_STORAGE_PREFIX = 'handoff:';
|
||||
const _HANDOFF_SUFFIX_DISMISSED_AT = 'dismissed_at';
|
||||
const _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT = 'summary_handled_at';
|
||||
const _MESSAGING_RAW_SOURCES = new Set(['weixin', 'telegram', 'discord', 'slack', 'email', 'wecom', 'wecom_callback', 'matrix']);
|
||||
const _MESSAGING_RAW_SOURCES = new Set(['weixin', 'telegram', 'discord', 'slack', 'email', 'wecom', 'wecom_callback', 'matrix', 'signal']);
|
||||
const _MESSAGING_SOURCE_LABELS = {
|
||||
weixin: 'WeChat',
|
||||
telegram: 'Telegram',
|
||||
@@ -2434,6 +3000,7 @@ const _MESSAGING_SOURCE_LABELS = {
|
||||
wecom: 'WeCom',
|
||||
wecom_callback: 'WeCom Callback',
|
||||
matrix: 'Matrix',
|
||||
signal: 'Signal',
|
||||
};
|
||||
|
||||
function _isMessagingSession(session) {
|
||||
@@ -2555,7 +3122,7 @@ function _isCliSession(session) {
|
||||
|
||||
function _sessionSourceLabel(filter, count) {
|
||||
const n = Number(count) || 0;
|
||||
return filter === 'cli' ? `CLI sessions (${n})` : `WebUI sessions (${n})`;
|
||||
return filter === 'cli' ? t('sessions_source_cli', n) : t('sessions_source_webui', n);
|
||||
}
|
||||
|
||||
function _clearSessionSourceTabCounts() {
|
||||
@@ -3903,7 +4470,7 @@ async function _ensureAllMessagesLoaded() {
|
||||
_loadingOlder = true;
|
||||
try {
|
||||
const sid = S.session.session_id;
|
||||
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0`, {timeoutMs:120000});
|
||||
const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_limit=all`, {timeoutMs:120000});
|
||||
// Guard: api() may have redirected (401) and returned undefined.
|
||||
if (!data || !data.session) return;
|
||||
// Session may have been switched while we awaited. Bail rather than
|
||||
@@ -5267,7 +5834,8 @@ function _shouldKeepLocalOnlyOptimisticSessionRow(local){
|
||||
function _dropStaleOptimisticSessionRow(sid){
|
||||
if(!sid) return;
|
||||
if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid, false);
|
||||
if(INFLIGHT&&INFLIGHT[sid]){
|
||||
// Retiring sidebar optimism must not retire the independent chat owner.
|
||||
if(INFLIGHT&&INFLIGHT[sid]&&!_hasOwnedOpenLiveStream(sid)){
|
||||
delete INFLIGHT[sid];
|
||||
if(typeof clearInflightState==='function') clearInflightState(sid);
|
||||
}
|
||||
@@ -7133,6 +7701,12 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere
|
||||
const childRenderable=!!(child&&child.session_id&&renderableChildIds.has(child.session_id));
|
||||
if(child&&child.session_id&&visibleBySid.has(child.session_id)) continue;
|
||||
const isForkChild=_isForkWithResolvableParent(child, sessionIdsInList)&&!(child&&child.pinned);
|
||||
const childRawRole=[
|
||||
child&&child.raw_source,
|
||||
child&&child.source_tag,
|
||||
child&&child.source,
|
||||
].map(source=>String(source||'').trim().toLowerCase()).find(Boolean)||'';
|
||||
const childIsDelegatedSubagent=_isChildSession(child)&&childRawRole==='subagent';
|
||||
const childLineageKey=child&&(child._lineage_root_id||child.lineage_root_id||child.parent_session_id);
|
||||
const isHiddenLineageReferenceChild=!!(child&&child.archived&&child.parent_session_id&&childLineageKey&&!child.pinned&&!childRenderable);
|
||||
if(!_isChildSession(child)&&!isForkChild&&!isHiddenLineageReferenceChild) continue;
|
||||
@@ -7157,11 +7731,10 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere
|
||||
hiddenArchivedChildTree.add(child.session_id);
|
||||
continue;
|
||||
}
|
||||
// Cross-surface rows (for example a WebUI continuation from a Telegram
|
||||
// conversation) should remain top-level when there is no WebUI-owned parent
|
||||
// row to stack under. But if the parent is visible in this same sidebar
|
||||
// render, attach normally — delegated subagent rows are also cross-source
|
||||
// relative to their WebUI parent and should not be forced into orphans.
|
||||
// Independent cross-surface rows (for example a WebUI continuation from a
|
||||
// Telegram conversation) remain top-level instead of nesting under an
|
||||
// external parent. Delegated subagents are also cross-source, but they are
|
||||
// parent-owned work and should still attach to the visible parent row.
|
||||
const parentSourceMarker=String(parentRow&&(
|
||||
parentRow.session_source||parentRow.raw_source||parentRow.source_tag||parentRow.source
|
||||
)||'').toLowerCase();
|
||||
@@ -7172,7 +7745,7 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere
|
||||
parentRow.session_source==='messaging'||
|
||||
(parentSourceMarker&&parentSourceMarker!=='webui'&&parentSourceMarker!=='subagent'&&parentSourceMarker!=='other'&&parentSourceMarker!=='fork')
|
||||
);
|
||||
if(parentRow&&child._cross_surface_child_session&&parentIsExternal){
|
||||
if(parentRow&&child._cross_surface_child_session&&parentIsExternal&&!childIsDelegatedSubagent){
|
||||
if(childRenderable) orphans.push({...child,_orphan_child_session:true});
|
||||
continue;
|
||||
}
|
||||
@@ -7561,7 +8134,50 @@ function _sidebarRowHasVisibleMessages(s, activeSidForSidebar){
|
||||
(S.session&&s.session_id===S.session.session_id&&(S.session.message_count||0)>0);
|
||||
}
|
||||
|
||||
function _isDelegatedSubagentRow(s){
|
||||
if(!_isChildSession(s)) return false;
|
||||
const role=[s.raw_source,s.source_tag,s.source].map(v=>String(v||'').trim().toLowerCase()).find(Boolean)||'';
|
||||
return role==='subagent';
|
||||
}
|
||||
|
||||
// Delegated subagents without a project_id take their nearest ancestor's project;
|
||||
// every other row keeps its own. Memoized per render, so each lineage resolves once.
|
||||
function _sidebarProjectResolver(rowsById){
|
||||
const memo=new Map();
|
||||
return function projectIdFor(s){
|
||||
if(!s) return null;
|
||||
const path=[];
|
||||
const onPath=new Set();
|
||||
let cur=s, result=null;
|
||||
while(cur){
|
||||
const sid=cur.session_id;
|
||||
if(memo.has(sid)){ result=memo.get(sid); break; }
|
||||
if(cur.project_id||!_isDelegatedSubagentRow(cur)){ result=cur.project_id||null; if(sid) path.push(sid); break; }
|
||||
if(onPath.has(sid)) break;
|
||||
onPath.add(sid); path.push(sid);
|
||||
cur=rowsById?rowsById.get(cur.parent_session_id):null;
|
||||
}
|
||||
for(const sid of path) memo.set(sid,result);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
function _sidebarRowsById(rows){
|
||||
const byId=new Map();
|
||||
for(const list of rows){
|
||||
if(!Array.isArray(list)) continue;
|
||||
for(const s of list) if(s&&s.session_id&&!byId.has(s.session_id)) byId.set(s.session_id,s);
|
||||
}
|
||||
return byId;
|
||||
}
|
||||
|
||||
// True when any row resolves to no project, using the filter's own ancestor map.
|
||||
function _sidebarHasUnprojectedRows(rows, projectIdFor){
|
||||
return rows.some(s=>!projectIdFor(s));
|
||||
}
|
||||
|
||||
function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){
|
||||
const projectIdFor=_sidebarProjectResolver(_sidebarRowsById([allMatched, typeof _sidebarReferenceSessions!=='undefined'?_sidebarReferenceSessions:null]));
|
||||
let cliSessionCount=0;
|
||||
const webuiProfileFiltered=[];
|
||||
const cliProfileFiltered=[];
|
||||
@@ -7580,10 +8196,11 @@ function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){
|
||||
const referenceRaw=isCli ? cliReferenceRaw : webuiReferenceRaw;
|
||||
const sessionsRaw=isCli ? cliSessionsRaw : webuiSessionsRaw;
|
||||
profileFiltered.push(s);
|
||||
const projectId=projectIdFor(s);
|
||||
if(_activeProject===NO_PROJECT_FILTER){
|
||||
if(s.project_id) continue;
|
||||
if(projectId) continue;
|
||||
} else if(_activeProject){
|
||||
if(s.project_id!==_activeProject) continue;
|
||||
if(projectId!==_activeProject) continue;
|
||||
}
|
||||
referenceRaw.push(s);
|
||||
if(s.archived){
|
||||
@@ -7607,6 +8224,7 @@ function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){
|
||||
cliReferenceRaw,
|
||||
webuiSessionsRaw,
|
||||
cliSessionsRaw,
|
||||
projectIdFor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7618,15 +8236,18 @@ function _partitionSidebarSessionRows(allMatched, activeSidForSidebar){
|
||||
// suppression context — silently hiding a visible child/fork whose archived
|
||||
// ancestor lives outside the current view. Scope the references to the same
|
||||
// project + source bucket as the render they feed before using them.
|
||||
function _scopedSidebarReferenceRows(isCli){
|
||||
// projectIdFor: the render's shared resolver; standalone callers get one over references + _allSessions.
|
||||
function _scopedSidebarReferenceRows(isCli, projectIdFor){
|
||||
if(typeof _sidebarReferenceSessions==='undefined'||!Array.isArray(_sidebarReferenceSessions)||!_sidebarReferenceSessions.length) return [];
|
||||
const resolve=projectIdFor||_sidebarProjectResolver(_sidebarRowsById([_sidebarReferenceSessions, typeof _allSessions!=='undefined'?_allSessions:null]));
|
||||
return _sidebarReferenceSessions.filter(s=>{
|
||||
if(!s) return false;
|
||||
// Source scope: only references in the same webui/cli bucket as this render.
|
||||
if(_isCliSession(s)!==!!isCli) return false;
|
||||
// Project scope: mirror _partitionSidebarSessionRows exactly.
|
||||
if(_activeProject===NO_PROJECT_FILTER){ if(s.project_id) return false; }
|
||||
else if(_activeProject){ if(s.project_id!==_activeProject) return false; }
|
||||
const projectId=resolve(s);
|
||||
if(_activeProject===NO_PROJECT_FILTER){ if(projectId) return false; }
|
||||
else if(_activeProject){ if(projectId!==_activeProject) return false; }
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -7732,19 +8353,20 @@ function renderSessionListFromCache(){
|
||||
cliReferenceRaw,
|
||||
webuiSessionsRaw,
|
||||
cliSessionsRaw,
|
||||
projectIdFor,
|
||||
}=_partitionSidebarSessionRows(allMatched, activeSidForSidebar);
|
||||
const referenceRaw=_sessionSourceFilter==='cli'?cliReferenceRaw:webuiReferenceRaw;
|
||||
const isCliView=_sessionSourceFilter==='cli';
|
||||
const sessions=_renderSidebarRowsFromRawSessions(sessionsRaw, [...referenceRaw, ..._scopedSidebarReferenceRows(isCliView)]);
|
||||
const sessions=_renderSidebarRowsFromRawSessions(sessionsRaw, [...referenceRaw, ..._scopedSidebarReferenceRows(isCliView, projectIdFor)]);
|
||||
// Server-provided source bucket counts are authoritative for the current
|
||||
// payload. When present, skip the expensive cross-bucket render/count pass;
|
||||
// null is a deliberate "not computed" sentinel consumed only by
|
||||
// _sessionSourceTabCount's fallback path below.
|
||||
const renderedWebuiSessionCount=_serverWebuiSessionCount===null
|
||||
? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false)]).length
|
||||
? _renderSidebarRowsFromRawSessions(webuiSessionsRaw, [...webuiReferenceRaw, ..._scopedSidebarReferenceRows(false, projectIdFor)]).length
|
||||
: null;
|
||||
const renderedCliSessionCount=_serverCliSessionCount===null
|
||||
? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true)]).length
|
||||
? _renderSidebarRowsFromRawSessions(cliSessionsRaw, [...cliReferenceRaw, ..._scopedSidebarReferenceRows(true, projectIdFor)]).length
|
||||
: null;
|
||||
const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount);
|
||||
const cliSessionTabCount=_sessionSourceTabCount('cli', renderedWebuiSessionCount, renderedCliSessionCount);
|
||||
@@ -7806,7 +8428,7 @@ function renderSessionListFromCache(){
|
||||
}
|
||||
// Project filter bar — show when there are real projects OR there are
|
||||
// unassigned sessions (so the Unassigned chip has something to filter to).
|
||||
const hasUnprojected=profileFiltered.some(s=>!s.project_id);
|
||||
const hasUnprojected=_sidebarHasUnprojectedRows(profileFiltered, projectIdFor);
|
||||
if(_allProjects.length>0||hasUnprojected){
|
||||
const bar=document.createElement('div');
|
||||
bar.className='project-bar';
|
||||
@@ -9055,10 +9677,31 @@ async function _handleShowAllProfilesStorageEvent(e){
|
||||
if(typeof renderSessionList==='function') await renderSessionList({deferWhileInteracting:false});
|
||||
}
|
||||
|
||||
function _handleUnreadStorageEvent(e){
|
||||
if(!e || !e.key) return;
|
||||
// A concurrent WebUI client on the same origin/profile changed one of the
|
||||
// unread stores. Both saves re-read the store, merge our cache into it (max for
|
||||
// viewed counts, tombstone-ordered for completion markers), keep the merged map
|
||||
// as the live cache, and write back only what the store is missing. So this
|
||||
// client re-asserts the acknowledgement it still holds instead of dropping it
|
||||
// and re-reading a value the other client's write already lost.
|
||||
if(e.key === SESSION_VIEWED_COUNTS_KEY
|
||||
|| (typeof e.key === 'string' && e.key.startsWith(SESSION_VIEWED_COUNTS_DELETED_PREFIX))){
|
||||
_saveSessionViewedCounts();
|
||||
}
|
||||
else if(e.key === SESSION_COMPLETION_UNREAD_KEY
|
||||
|| e.key === SESSION_COMPLETION_UNREAD_CLEARED_KEY
|
||||
|| (typeof e.key === 'string'
|
||||
&& e.key.startsWith(SESSION_COMPLETION_UNREAD_CLEARED_PREFIX))){
|
||||
_saveSessionCompletionUnread();
|
||||
}
|
||||
}
|
||||
|
||||
if(typeof window!=='undefined'){
|
||||
window.addEventListener('storage', (e) => {
|
||||
void _handleActiveSessionStorageEvent(e);
|
||||
void _handleShowAllProfilesStorageEvent(e);
|
||||
void _handleUnreadStorageEvent(e);
|
||||
});
|
||||
window.addEventListener('popstate', () => {
|
||||
const sid=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;
|
||||
|
||||
+2
-1
@@ -2594,7 +2594,8 @@
|
||||
@media (hover: hover) {
|
||||
.attach-thumb:hover{filter:brightness(1.05);transform:scale(1.04);}
|
||||
}
|
||||
textarea#msg{width:100%;background:transparent;border:none;outline:none;color:var(--text);font-size:16px;line-height:1.65;padding:12px 16px 6px;resize:none;min-height:44px;max-height:200px;font-family:inherit;}
|
||||
textarea#msg{width:100%;background:transparent;border:none;outline:none;color:var(--text);font-size:16px;line-height:1.65;padding:12px 16px 6px;resize:none;min-height:44px;max-height:200px;overflow-y:auto;field-sizing:content;font-family:inherit;}
|
||||
textarea#msg:placeholder-shown{field-sizing:fixed;}
|
||||
textarea#msg::placeholder{color:var(--muted);}
|
||||
.composer-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:6px 10px 10px;position:relative;container-type:inline-size;container-name:composer-footer;}
|
||||
.composer-left{display:flex;align-items:center;gap:4px;min-width:0;flex:1;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;}
|
||||
|
||||
+744
-65
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,210 @@
|
||||
"""Composer box metrics derived from the REAL stylesheet.
|
||||
|
||||
``autoResize()`` in ``static/messages.js`` decides between skipping the height
|
||||
round trip and doing a full remeasure from *computed* styles: the composer's
|
||||
natural one-row border box is ``line-height + vertical padding + borders`` and it
|
||||
is compared against the CSS ``min-height`` (see the guard discussed in
|
||||
``docs/UIUX-GUIDE.md``, "Composer sizing").
|
||||
|
||||
Hardcoding those numbers in a test fixture is how the first #7604 fixture went
|
||||
wrong: it used the 18px ``data-font-size="large"`` composer (29.7px line-height,
|
||||
47.7px natural row) as if it were the stylesheet's 16px default. Parse
|
||||
``static/style.css`` here instead and derive every dimension from it, so the
|
||||
fixtures follow the stylesheet and cannot quietly encode the wrong font size.
|
||||
|
||||
Usage::
|
||||
|
||||
from tests._composer_metrics import CONFIG_DEFAULT, CONFIG_LARGE, composer_metrics
|
||||
|
||||
m = composer_metrics(CONFIG_LARGE)
|
||||
m.natural_row # 47.7: what one row of text wants (border box)
|
||||
m.offset_height # 48: what a browser reports (integer, min-height floored)
|
||||
m.old_ceiling # ceil(min-height) + 1: the pre-#7604 skip ceiling
|
||||
m.new_ceiling # ceil(max(min-height, natural row)) + 1: with the fix
|
||||
m.computed # {"lineHeight": "29.7px", ...} for a getComputedStyle stub
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
STYLE_CSS = (Path(__file__).parents[1] / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _declarations(rules: list[str]) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for rule in rules:
|
||||
for decl in rule.split(";"):
|
||||
if ":" not in decl:
|
||||
continue
|
||||
prop, _, value = decl.partition(":")
|
||||
out[prop.strip().lower()] = value.strip()
|
||||
return out
|
||||
|
||||
|
||||
def _rule(pattern: str, *, flags: int = re.M) -> str | None:
|
||||
m = re.search(pattern, STYLE_CSS, flags)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _px(value: str) -> float:
|
||||
return float(value.replace("px", "").strip())
|
||||
|
||||
|
||||
def _padding_shorthand(value: str) -> tuple[float, float]:
|
||||
"""(padding-top, padding-bottom) from a CSS padding shorthand."""
|
||||
parts = value.split()
|
||||
if len(parts) == 1: # all sides
|
||||
return _px(parts[0]), _px(parts[0])
|
||||
if len(parts) == 2: # vertical horizontal
|
||||
return _px(parts[0]), _px(parts[0])
|
||||
if len(parts) == 3: # top horizontal bottom
|
||||
return _px(parts[0]), _px(parts[2])
|
||||
return _px(parts[0]), _px(parts[2])
|
||||
|
||||
|
||||
# The base composer rule: `textarea#msg{...}` (skin rules are prefixed with
|
||||
# `:root[data-skin=...]`, so anchor the match at the start of a line).
|
||||
_BASE = _declarations([_rule(r"^\s*textarea#msg\{([^}]*)\}") or ""])
|
||||
_BASE_FONT_SIZE = _px(_BASE["font-size"])
|
||||
_BASE_LINE_RATIO = float(_BASE["line-height"])
|
||||
_BASE_PAD_TOP, _BASE_PAD_BOTTOM = _padding_shorthand(_BASE["padding"])
|
||||
_MIN_HEIGHT = _px(_BASE["min-height"])
|
||||
# `border:none` / unset borders contribute nothing to the computed border box.
|
||||
_BORDER = _px(_BASE.get("border-width", "0px")) if _BASE.get("border-width") else 0.0
|
||||
|
||||
# `:root[data-font-size="large"] #msg { font-size: 18px; }` overrides, i.e. the
|
||||
# appearance setting a real user can switch on.
|
||||
_FONT_SIZE_OVERRIDES = {
|
||||
name: _px(size)
|
||||
for name, size in re.findall(
|
||||
r':root\[data-font-size="(\w+)"\]\s*#msg\s*\{\s*font-size:\s*([\d.]+px)', STYLE_CSS
|
||||
)
|
||||
}
|
||||
|
||||
# Skins that restyle the composer's font metrics, e.g.
|
||||
# `:root[data-skin="graphite"] textarea#msg{...font-size:14px;...line-height:1.45;}`.
|
||||
_SKIN_OVERRIDES = {
|
||||
name: _declarations([body])
|
||||
for name, body in re.findall(
|
||||
r':root\[data-skin="([\w-]+)"\]\s*textarea#msg\{([^}]*)\}', STYLE_CSS
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ComposerMetrics:
|
||||
"""One composer configuration, all values derived from ``static/style.css``."""
|
||||
|
||||
name: str
|
||||
font_size: float
|
||||
line_height: float
|
||||
padding_top: float
|
||||
padding_bottom: float
|
||||
border_top: float
|
||||
border_bottom: float
|
||||
min_height: float
|
||||
|
||||
@property
|
||||
def natural_row(self) -> float:
|
||||
"""The border box one row of text wants (line-height + padding + borders)."""
|
||||
return (
|
||||
self.line_height
|
||||
+ self.padding_top
|
||||
+ self.padding_bottom
|
||||
+ self.border_top
|
||||
+ self.border_bottom
|
||||
)
|
||||
|
||||
@property
|
||||
def offset_height(self) -> int:
|
||||
"""What a browser reports for a one-row composer: an integer, min-height floored."""
|
||||
return round(max(self.natural_row, self.min_height))
|
||||
|
||||
@property
|
||||
def old_ceiling(self) -> int:
|
||||
"""The pre-#7604 skip ceiling: ``ceil(min-height) + 1`` (min-height only)."""
|
||||
return math.ceil(self.min_height) + 1
|
||||
|
||||
@property
|
||||
def new_ceiling(self) -> int:
|
||||
"""The skip ceiling with the natural one-row height considered."""
|
||||
return math.ceil(max(self.min_height, self.natural_row)) + 1
|
||||
|
||||
@property
|
||||
def skip_was_reachable(self) -> bool:
|
||||
return self.offset_height <= self.old_ceiling
|
||||
|
||||
@property
|
||||
def skip_is_reachable(self) -> bool:
|
||||
return self.offset_height <= self.new_ceiling
|
||||
|
||||
@property
|
||||
def content_two_rows(self) -> int:
|
||||
"""scrollHeight for a two-row value in the same box."""
|
||||
return round(2 * self.line_height + self.padding_top + self.padding_bottom)
|
||||
|
||||
@property
|
||||
def oversized_box(self) -> int:
|
||||
"""A composer several rows tall that must still remeasure back down."""
|
||||
return round(self.natural_row + 3 * self.line_height)
|
||||
|
||||
@property
|
||||
def computed(self) -> dict[str, str]:
|
||||
"""The ``getComputedStyle`` payload the JS guard reads."""
|
||||
|
||||
def px(value: float) -> str:
|
||||
return f"{value:g}px"
|
||||
|
||||
return {
|
||||
"minHeight": px(self.min_height),
|
||||
"lineHeight": px(self.line_height),
|
||||
"paddingTop": px(self.padding_top),
|
||||
"paddingBottom": px(self.padding_bottom),
|
||||
"borderTopWidth": px(self.border_top),
|
||||
"borderBottomWidth": px(self.border_bottom),
|
||||
"fontSize": px(self.font_size),
|
||||
}
|
||||
|
||||
|
||||
def composer_metrics(
|
||||
font_size: float | None = None,
|
||||
line_height_ratio: float | None = None,
|
||||
*,
|
||||
name: str = "custom",
|
||||
) -> ComposerMetrics:
|
||||
"""Metrics for the stylesheet's composer at ``font_size`` (default: 16px)."""
|
||||
size = _BASE_FONT_SIZE if font_size is None else font_size
|
||||
ratio = _BASE_LINE_RATIO if line_height_ratio is None else line_height_ratio
|
||||
return ComposerMetrics(
|
||||
name=name,
|
||||
font_size=size,
|
||||
line_height=size * ratio,
|
||||
padding_top=_BASE_PAD_TOP,
|
||||
padding_bottom=_BASE_PAD_BOTTOM,
|
||||
border_top=_BORDER,
|
||||
border_bottom=_BORDER,
|
||||
min_height=_MIN_HEIGHT,
|
||||
)
|
||||
|
||||
|
||||
#: The stylesheet default (16px), i.e. `textarea#msg` with no appearance override.
|
||||
CONFIG_DEFAULT = composer_metrics(name="default-16px")
|
||||
|
||||
#: The `data-font-size` appearance overrides.
|
||||
CONFIG_SMALL = composer_metrics(_FONT_SIZE_OVERRIDES.get("small"), name="small-14px")
|
||||
CONFIG_LARGE = composer_metrics(_FONT_SIZE_OVERRIDES.get("large"), name="large-18px")
|
||||
CONFIG_XLARGE = composer_metrics(_FONT_SIZE_OVERRIDES.get("xlarge"), name="xlarge-20px")
|
||||
|
||||
#: A skin that restyles the composer metrics (14px / 1.45).
|
||||
_SKIN = _SKIN_OVERRIDES.get("graphite", {})
|
||||
CONFIG_SKIN = composer_metrics(
|
||||
_px(_SKIN["font-size"]) if "font-size" in _SKIN else None,
|
||||
float(_SKIN["line-height"]) if "line-height" in _SKIN else None,
|
||||
name="skin-graphite-14px",
|
||||
)
|
||||
|
||||
#: Every composer configuration the stylesheet can produce.
|
||||
COMPOSER_CONFIGS = (CONFIG_DEFAULT, CONFIG_SMALL, CONFIG_LARGE, CONFIG_XLARGE, CONFIG_SKIN)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Node-harness scaffolding shared by the unread-store persistence tests.
|
||||
|
||||
``static/sessions.js`` persists two unread stores (``hermes-session-viewed-counts``
|
||||
and ``hermes-session-completion-unread``) through a small set of pure helpers:
|
||||
reading a stored map, asking whether a session still exists, and merging a cache
|
||||
into a store under the monotonic / tombstone rules. The node harnesses in this
|
||||
suite work by extracting individual functions from the module by name, so a
|
||||
harness that drives a persistence path must also provide those helpers.
|
||||
|
||||
Keeping the list here means every harness injects the same set from the same
|
||||
source, and the store keys / tombstone cap always match the module.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||
|
||||
_HELPER_NAMES = (
|
||||
"_readStoredJsonMap",
|
||||
"_sessionExistsForUnreadState",
|
||||
"_sessionListLoaded",
|
||||
"_sessionViewedCountRecord",
|
||||
"_sessionViewedCountValue",
|
||||
"_sessionViewedRecordWins",
|
||||
"_sessionTranscriptGenerationForUnread",
|
||||
"_mergeSessionViewedCounts",
|
||||
"_sessionViewedCountDeletedKey",
|
||||
"_readSessionViewedCountDeletions",
|
||||
"_recordSessionViewedCountDeleted",
|
||||
"_markerLosesToUnreadClear",
|
||||
"_sessionCompletionUnreadOrder",
|
||||
"_nextSessionCompletionUnreadOrder",
|
||||
"_sessionCompletionUnreadClearedKey",
|
||||
"_parseSessionCompletionUnreadClearedKey",
|
||||
"_readPersistedSessionCompletionUnreadCleared",
|
||||
"_readSessionCompletionUnreadCleared",
|
||||
"_mergeSessionCompletionUnread",
|
||||
"_writeSessionCompletionUnreadCleared",
|
||||
)
|
||||
|
||||
# Declared from the module source so a harness cannot drift from the values the
|
||||
# module uses. Harnesses keep their own stub keys for the two older stores.
|
||||
_CONST_NAMES = (
|
||||
"SESSION_VIEWED_COUNTS_DELETED_PREFIX",
|
||||
"SESSION_VIEWED_COUNTS_DELETED_TTL_MS",
|
||||
"SESSION_COMPLETION_UNREAD_CLEARED_KEY",
|
||||
"SESSION_COMPLETION_UNREAD_CLEARED_PREFIX",
|
||||
"SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS",
|
||||
)
|
||||
|
||||
_CONST_FALLBACKS = {
|
||||
"SESSION_COMPLETION_UNREAD_CLEARED_KEY": (
|
||||
"const SESSION_COMPLETION_UNREAD_CLEARED_KEY = "
|
||||
"'hermes-session-completion-unread-cleared';"
|
||||
),
|
||||
"SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS": (
|
||||
"const SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS = 7 * 24 * 60 * 60 * 1000;"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _function_source(name: str) -> str:
|
||||
"""Brace-match a top-level ``function name(...) { ... }`` definition."""
|
||||
marker = f"function {name}("
|
||||
if marker not in SESSIONS_JS:
|
||||
return ""
|
||||
start = SESSIONS_JS.index(marker)
|
||||
brace = SESSIONS_JS.index("{", start)
|
||||
depth = 0
|
||||
for i in range(brace, len(SESSIONS_JS)):
|
||||
ch = SESSIONS_JS[i]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return SESSIONS_JS[start:i + 1]
|
||||
raise AssertionError(f"could not brace-match {name}")
|
||||
|
||||
|
||||
def _const_source(name: str) -> str:
|
||||
for line in SESSIONS_JS.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(f"const {name} ="):
|
||||
return stripped
|
||||
return _CONST_FALLBACKS.get(name, "")
|
||||
|
||||
|
||||
# Ready to interpolate into a node harness.
|
||||
BLOCK = "\n".join(
|
||||
["var _sessionCompletionUnreadClearedMemory = {};\n"]
|
||||
+ [_const_source(name) for name in _CONST_NAMES]
|
||||
+ [_function_source(name) for name in _HELPER_NAMES if f"function {name}(" in SESSIONS_JS]
|
||||
)
|
||||
@@ -39,6 +39,7 @@ SCENARIO = os.environ.get("LIFECYCLE_SCENARIO", "normal").strip() or "normal"
|
||||
TOOL_NAME = "read_file"
|
||||
TOOL_ID = "lifecycle-tool-1"
|
||||
TEST_BITE = os.environ.get("LIFECYCLE_TEST_BITE", "").strip()
|
||||
_FRAME_PROOF_BITES = {"settle-worklog-frame-proof", "sidebar-idle-missing-terminal"}
|
||||
GATEWAY_ACTIVITY_TIMEOUT = 60.0
|
||||
ANCHOR_SCENE_PERSIST_TIMEOUT = 60.0
|
||||
ANCHOR_SCENE_PROJECTION_TIMEOUT = 10_000
|
||||
@@ -267,8 +268,17 @@ def _start_webui_server(repo_root: Path, env: dict, artifact_dir: Path):
|
||||
suffix = "" if attempts == 1 else f"-attempt-{attempt + 1}"
|
||||
log_path = artifact_dir / f"server{suffix}.log"
|
||||
log = log_path.open("w", encoding="utf-8")
|
||||
command = [sys.executable, str(repo_root / "server.py")]
|
||||
barrier_dir = env.get("HERMES_TEST_TERMINAL_BARRIER_DIR")
|
||||
if barrier_dir:
|
||||
command = [
|
||||
sys.executable,
|
||||
str(repo_root / "tests" / "sidebar_terminal_barrier_server.py"),
|
||||
str(repo_root),
|
||||
barrier_dir,
|
||||
]
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, str(repo_root / "server.py")],
|
||||
command,
|
||||
cwd=repo_root,
|
||||
env=run_env,
|
||||
stdout=log,
|
||||
@@ -286,6 +296,50 @@ def _start_webui_server(repo_root: Path, env: dict, artifact_dir: Path):
|
||||
raise RuntimeError(f"WebUI server did not become healthy{detail}")
|
||||
|
||||
|
||||
def _exercise_sidebar_terminal_handoff(page, barrier_dir: Path, artifact_dir: Path):
|
||||
"""Order the real idle-list response before the real terminal SSE write."""
|
||||
deadline = time.monotonic() + 10
|
||||
while not (barrier_dir / "ready").exists():
|
||||
if time.monotonic() >= deadline:
|
||||
raise AssertionError("terminal HTTP barrier was not reached")
|
||||
page.wait_for_timeout(10)
|
||||
observation = page.evaluate(
|
||||
"""async () => {
|
||||
const sid = S.session.session_id;
|
||||
const stream = S.activeStreamId;
|
||||
const inflight = INFLIGHT[sid];
|
||||
const source = LIVE_STREAMS[sid] && LIVE_STREAMS[sid].source;
|
||||
const registries = window._liveAnchorRegistries;
|
||||
const registry = registries && registries.get(stream);
|
||||
await renderSessionList();
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const live = document.querySelector('#liveAssistantTurn');
|
||||
return {
|
||||
serverIdle: _allSessions.some(row => row.session_id === sid && _isServerIdleSessionRow(row)),
|
||||
paneRetained: S.busy === true && S.activeStreamId === stream,
|
||||
inflightRetained: !!inflight && INFLIGHT[sid] === inflight,
|
||||
sourceRetained: !!source && source.readyState === EventSource.OPEN &&
|
||||
LIVE_STREAMS[sid] && LIVE_STREAMS[sid].source === source,
|
||||
registryRetained: !!registry && registries.get(stream) === registry,
|
||||
liveRows: live ? live.querySelectorAll('[data-anchor-scene-row="1"]').length : 0,
|
||||
};
|
||||
}"""
|
||||
)
|
||||
(artifact_dir / "sidebar-before-terminal.json").write_text(
|
||||
json.dumps(observation, indent=2), encoding="utf-8"
|
||||
)
|
||||
page.screenshot(path=str(artifact_dir / "sidebar-before-terminal.png"), full_page=True)
|
||||
for invariant in ("serverIdle", "paneRetained", "inflightRetained", "sourceRetained", "registryRetained"):
|
||||
assert observation[invariant] is True, {
|
||||
"message": "idle sidebar response revoked the live terminal handoff",
|
||||
"invariant": invariant,
|
||||
"observation": observation,
|
||||
}
|
||||
assert observation["liveRows"] > 0, observation
|
||||
if TEST_BITE != "sidebar-idle-missing-terminal":
|
||||
(barrier_dir / "release").touch()
|
||||
|
||||
|
||||
class DeterministicGateway:
|
||||
"""A localhost-only Gateway Runs server with test-controlled phase gates."""
|
||||
|
||||
@@ -733,11 +787,12 @@ def main() -> int:
|
||||
"drop-anchor-persistence",
|
||||
"drop-terminal-anchor-row",
|
||||
"settle-worklog-frame-proof",
|
||||
"sidebar-idle-missing-terminal",
|
||||
}:
|
||||
raise ValueError(
|
||||
f"Unsupported LIFECYCLE_TEST_BITE {TEST_BITE!r}; "
|
||||
"expected one of '', 'drop-anchor-persistence', "
|
||||
"'drop-terminal-anchor-row', 'settle-worklog-frame-proof'"
|
||||
"'drop-terminal-anchor-row', 'settle-worklog-frame-proof', 'sidebar-idle-missing-terminal'"
|
||||
)
|
||||
if TEST_BITE == "drop-terminal-anchor-row" and scenario != "terminal-error":
|
||||
raise ValueError(
|
||||
@@ -782,6 +837,12 @@ def main() -> int:
|
||||
"NO_PROXY": "127.0.0.1,localhost",
|
||||
"no_proxy": "127.0.0.1,localhost",
|
||||
})
|
||||
env.pop("HERMES_TEST_TERMINAL_BARRIER_DIR", None)
|
||||
terminal_barrier_dir = None
|
||||
if TEST_BITE in _FRAME_PROOF_BITES and scenario == "normal":
|
||||
terminal_barrier_dir = state_dir / "terminal-barrier"
|
||||
terminal_barrier_dir.mkdir()
|
||||
env["HERMES_TEST_TERMINAL_BARRIER_DIR"] = str(terminal_barrier_dir)
|
||||
proc = None
|
||||
log = None
|
||||
log_path = None
|
||||
@@ -873,7 +934,7 @@ def main() -> int:
|
||||
# must never expose an expanded empty Worklog shell or an activity-less
|
||||
# gap. Sample after rAF callbacks for a frame have drained; DOM mutations
|
||||
# within one task or earlier rAF callbacks are not user-visible frames.
|
||||
if TEST_BITE == "settle-worklog-frame-proof":
|
||||
if TEST_BITE in _FRAME_PROOF_BITES:
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
const samples = [];
|
||||
@@ -941,12 +1002,17 @@ def main() -> int:
|
||||
timeout=10000,
|
||||
)
|
||||
gateway.release_terminal.set()
|
||||
if terminal_barrier_dir is not None:
|
||||
_exercise_sidebar_terminal_handoff(page, terminal_barrier_dir, artifact_dir)
|
||||
page.wait_for_function(
|
||||
"""text => typeof S !== 'undefined' && S.busy === false && !S.activeStreamId &&
|
||||
((document.querySelector('#msgInner') || {}).innerText || '').includes(text)""",
|
||||
arg=FINAL_TEXT,
|
||||
timeout=15000,
|
||||
timeout=11000 if TEST_BITE == "sidebar-idle-missing-terminal" else 15000,
|
||||
)
|
||||
if TEST_BITE == "sidebar-idle-missing-terminal":
|
||||
assert not (terminal_barrier_dir / "release").exists(), "terminal frame was unexpectedly released"
|
||||
print("OK missing terminal: canonical snapshot settled the still-OPEN transport before terminal delivery")
|
||||
else:
|
||||
gateway.release_terminal.set()
|
||||
page.wait_for_function(
|
||||
@@ -958,7 +1024,7 @@ def main() -> int:
|
||||
)
|
||||
session_id = page.evaluate("S.session && S.session.session_id")
|
||||
assert session_id, "active session id missing after settlement"
|
||||
if TEST_BITE == "settle-worklog-frame-proof":
|
||||
if TEST_BITE in _FRAME_PROOF_BITES:
|
||||
page.wait_for_timeout(100)
|
||||
settle_proof = page.evaluate(
|
||||
"window.__lifecycleSettleFrameProof && window.__lifecycleSettleFrameProof.stop()"
|
||||
@@ -1176,6 +1242,8 @@ def main() -> int:
|
||||
exit_code = 1
|
||||
return 1
|
||||
finally:
|
||||
if terminal_barrier_dir is not None:
|
||||
(terminal_barrier_dir / "release").touch()
|
||||
gateway.close()
|
||||
if browser is not None:
|
||||
browser.close()
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real page/loadSession/renderers with deterministic session and SSE fixtures.
|
||||
|
||||
No agent, provider, production state, or credentials. Tests DOM reconstruction,
|
||||
not a real provider/network reconnect.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
from browser_conversation_lifecycle import _start_webui_server, _terminate_process
|
||||
|
||||
ROOT = Path(os.environ.get('WEBUI_TEST_ROOT', Path(__file__).resolve().parent.parent))
|
||||
|
||||
|
||||
def fixture(count):
|
||||
tools = [dict(name='terminal', tid=f'tool-{i}', args={'command': f'printf result-{i}'},
|
||||
preview=f'result-{i}', snippet=f'result-{i}', done=True) for i in range(count)]
|
||||
rows = [dict(row_id=f'tool:{t["tid"]}', local_id=t['tid'], role='tool', kind='tool_call',
|
||||
source_event_type='tool_complete', status='completed', order_index=i,
|
||||
tool=t, payload=t) for i, t in enumerate(tools)]
|
||||
return dict(stream_id='run-fixture', last_seq=1000, last_event_id='run-fixture:1000',
|
||||
messages=[], tool_calls=tools, last_assistant_text='', last_reasoning_text='',
|
||||
anchor_activity_scene=dict(version='activity_scene_v1',
|
||||
identity=dict(session_id='fixture', stream_id='run-fixture', run_id='run-fixture'),
|
||||
activity_rows=rows))
|
||||
|
||||
|
||||
INIT = """
|
||||
// A controlling service worker bypasses Playwright's page route fixtures in
|
||||
// WebKit. This test targets reconnect rendering, not PWA cache behavior.
|
||||
if(window===window.top && 'serviceWorker' in navigator){
|
||||
navigator.serviceWorker.register=()=>Promise.reject(new Error('Disabled in reconnect harness'));
|
||||
}
|
||||
window.fixtureSources=[];
|
||||
class FixtureEventSource {
|
||||
static OPEN=1; static CONNECTING=0; static CLOSED=2;
|
||||
constructor(url){this.url=String(url);this.readyState=1;this.listeners={};window.fixtureSources.push(this);}
|
||||
addEventListener(name,fn){(this.listeners[name]||=[]).push(fn);}
|
||||
removeEventListener(){}
|
||||
close(){this.readyState=2;}
|
||||
emit(name,data,id){for(const fn of this.listeners[name]||[])fn({data:JSON.stringify(data),lastEventId:id||''});}
|
||||
}
|
||||
window.EventSource=FixtureEventSource;
|
||||
"""
|
||||
|
||||
|
||||
def session_route(session, sid, workspace):
|
||||
def handle(route):
|
||||
query = parse_qs(urlsplit(route.request.url).query)
|
||||
requested = query.get('session_id', [''])[0]
|
||||
data = session if requested == sid else dict(
|
||||
session_id='idle-fixture', messages=[], message_count=0,
|
||||
tool_calls=[], workspace=workspace)
|
||||
route.fulfill(json={'session': data})
|
||||
return handle
|
||||
|
||||
|
||||
def main():
|
||||
tool_count = int(os.environ.get('TOOL_COUNT', '470'))
|
||||
snapshot = fixture(tool_count)
|
||||
stream_id = snapshot['stream_id']
|
||||
sid = 'fixture'
|
||||
with tempfile.TemporaryDirectory(prefix='webui-reconnect-') as temp:
|
||||
state = Path(temp)
|
||||
env = {k:os.environ[k] for k in ('PATH','SYSTEMROOT','TMPDIR') if k in os.environ}
|
||||
env.update(HOME=temp, HERMES_HOME=temp, HERMES_BASE_HOME=temp,
|
||||
HERMES_WEBUI_STATE_DIR=str(state/'webui'), HERMES_CONFIG_PATH=str(state/'config.yaml'),
|
||||
HERMES_WEBUI_HOST='127.0.0.1', HERMES_WEBUI_SKIP_ONBOARDING='1',
|
||||
HERMES_WEBUI_AGENT_DIR=str(state/'no-agent'))
|
||||
proc, log, _, base = _start_webui_server(ROOT, env, state)
|
||||
results = []
|
||||
try:
|
||||
with sync_playwright() as pw:
|
||||
for engine in os.environ.get('BROWSERS','chromium,webkit').split(','):
|
||||
browser = getattr(pw,engine).launch(headless=True)
|
||||
try:
|
||||
for mode in os.environ.get('MODES','compact_worklog,transparent_stream,hide_all_activity').split(','):
|
||||
for width in [1280,390]:
|
||||
context = browser.new_context(viewport={'width':width,'height':844},bypass_csp=True)
|
||||
context.add_init_script(INIT)
|
||||
page = context.new_page()
|
||||
|
||||
errors=[]
|
||||
page.on('pageerror',lambda e, errors=errors:errors.append(str(e)))
|
||||
session = dict(session_id=sid,title='Reconnect performance fixture',model='',
|
||||
workspace=temp,messages=[],message_count=0,tool_calls=[],
|
||||
active_stream_id=stream_id,pending_user_message='Inspect the fixture',
|
||||
pending_started_at=1,runtime_journal_snapshot=snapshot)
|
||||
page.route('**/api/session?*',session_route(session,sid,temp))
|
||||
page.route('**/api/chat/stream/status?*',lambda r:r.fulfill(json={'active':True}))
|
||||
page.goto(base,wait_until='load')
|
||||
# WebKit's wait_for_function uses eval, blocked by
|
||||
# the app CSP even with bypass_csp. Inspector
|
||||
# evaluation still works; wait on actual boot state.
|
||||
deadline=time.monotonic()+30
|
||||
while not page.evaluate("typeof loadSession==='function' && S._bootReady === true"):
|
||||
assert time.monotonic()<deadline, ('boot timeout', errors)
|
||||
page.wait_for_timeout(50)
|
||||
page.evaluate("""mode=>{
|
||||
window._chatActivityDisplayMode=mode;window._showThinking=true;
|
||||
window._simplifiedToolCalling=true;
|
||||
window.redrawCount=0;
|
||||
const render=window._renderLiveAnchorActivitySceneForStream;
|
||||
window._renderLiveAnchorActivitySceneForStream=function(...args){window.redrawCount++;return render(...args);};
|
||||
}""",mode)
|
||||
measured=page.evaluate("""async sid=>{
|
||||
const t=performance.now();await loadSession(sid);
|
||||
const loadMs=performance.now()-t;
|
||||
const deadline=performance.now()+5000;
|
||||
while(!fixtureSources.some(s=>s.url.includes('api/chat/stream?'))&&performance.now()<deadline){
|
||||
await new Promise(r=>setTimeout(r,10));
|
||||
}
|
||||
return {ms:loadMs,redraws:window.redrawCount,
|
||||
tools:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length,
|
||||
busy:S.busy,
|
||||
sources:fixtureSources.filter(s=>s.url.includes('api/chat/stream?')).map(s=>s.url)};
|
||||
}""",sid)
|
||||
result=dict(engine=engine,mode=mode,width=width,
|
||||
**{k:v for k,v in measured.items() if k!='sources'})
|
||||
print(json.dumps(result),flush=True)
|
||||
results.append(result)
|
||||
if not os.environ.get('MEASURE_BASELINE'):
|
||||
assert measured['redraws']<=2,result
|
||||
expected=0 if mode=='hide_all_activity' else tool_count
|
||||
assert measured['tools']==expected,result
|
||||
assert measured['busy'],result
|
||||
assert measured['sources'], (result, errors)
|
||||
cursor=parse_qs(urlsplit(measured['sources'][-1]).query)
|
||||
assert cursor['after_seq']==[str(snapshot['last_seq'])], (result,errors)
|
||||
if mode!='hide_all_activity':
|
||||
# Subsequent real SSE handler updates still paint the existing owner.
|
||||
updated=page.evaluate("""({sid,stream})=>{
|
||||
const source=fixtureSources.findLast(s=>s.url.includes('api/chat/stream?')&&s.readyState===1);
|
||||
if(!source)throw new Error('missing chat stream');
|
||||
source.emit('tool',{name:'terminal',tid:'after-reconnect',args:{command:'printf later'},preview:'later'},stream+':1001');
|
||||
source.emit('tool_complete',{name:'terminal',tid:'after-reconnect',preview:'LATER RESULT',duration:1},stream+':1002');
|
||||
return {rows:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length,
|
||||
result:document.querySelector('#liveAssistantTurn').textContent.includes('LATER RESULT')};
|
||||
}""",{'sid':sid,'stream':stream_id})
|
||||
assert updated=={'rows':expected+1,'result':True},updated
|
||||
switched=page.evaluate("""async sid=>{
|
||||
await loadSession('idle-fixture');
|
||||
await loadSession(sid);
|
||||
return {rows:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length,
|
||||
result:document.querySelector('#liveAssistantTurn').textContent.includes('LATER RESULT')};
|
||||
}""",sid)
|
||||
assert switched==updated,switched
|
||||
artifact=os.environ.get('SCREENSHOT_DIR')
|
||||
if artifact:
|
||||
Path(artifact).mkdir(parents=True,exist_ok=True)
|
||||
page.screenshot(path=str(Path(artifact)/f'{engine}-{mode}-{width}.png'))
|
||||
assert not errors,errors
|
||||
context.close()
|
||||
finally:
|
||||
browser.close()
|
||||
output=os.environ.get('METRICS_FILE')
|
||||
if output:
|
||||
Path(output).write_text(json.dumps(results,indent=2))
|
||||
finally:
|
||||
_terminate_process(proc)
|
||||
log.close()
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
main()
|
||||
@@ -231,6 +231,44 @@ def _reset_password_hash_cache():
|
||||
_invalidate_password_hash_cache()
|
||||
|
||||
|
||||
def _strip_leaked_webui_password_env() -> None:
|
||||
"""Remove a leaked HERMES_WEBUI_PASSWORD between tests (#7168 review).
|
||||
|
||||
bootstrap.py runs _load_repo_dotenv() at import time, which copies values
|
||||
from the developer's real repo .env straight into os.environ. When any
|
||||
test imports bootstrap mid-session (e.g. tests/test_bootstrap_foreground.py
|
||||
via its import_bootstrap fixture), a local .env containing
|
||||
HERMES_WEBUI_PASSWORD leaks into the process environment OUTSIDE
|
||||
monkeypatch's undo scope. Every later test then sees is_auth_enabled()
|
||||
True and no-handler cookie helpers raise spurious
|
||||
"build_profile_cookie requires a request handler" errors — exactly the
|
||||
#5588 failure shape, but sourced from the repo .env instead of the hash
|
||||
cache. Tests that legitimately enable auth set the var themselves AFTER
|
||||
this strip; an intentionally-empty value ("") is preserved so
|
||||
ctl.sh-style override semantics keep working.
|
||||
|
||||
HERMES_COMMAND gets the same treatment (#7168 re-gate round 7): a local
|
||||
.env carrying HERMES_COMMAND leaks past bootstrap imports and redirects
|
||||
gateway_restart._resolve_hermes_command() away from its mocked
|
||||
shutil.which result, failing every later active-profile-restart test
|
||||
with a machine-specific CLI path. Upstream code has no
|
||||
HERMES_COMMAND override, so stripping a leaked value restores exact
|
||||
upstream semantics.
|
||||
"""
|
||||
if os.environ.get("HERMES_WEBUI_PASSWORD") == "":
|
||||
pass # intentional empty override preserved for the password var
|
||||
else:
|
||||
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
|
||||
os.environ.pop("HERMES_COMMAND", None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _strip_leaked_webui_password():
|
||||
_strip_leaked_webui_password_env()
|
||||
yield
|
||||
_strip_leaked_webui_password_env()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _invalidate_providers_cache():
|
||||
"""Clear the /api/providers TTL cache around every test (#6010).
|
||||
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,40 @@
|
||||
"""Test-only server for the public settle-frame gate's cross-channel race.
|
||||
|
||||
Hold the actual HTTP terminal frame after the worker has finished. The browser
|
||||
keeps its native, open EventSource while /api/sessions independently reports idle.
|
||||
No browser state, renderer, persistence result, or terminal payload is mocked.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit("expected repository root and isolated barrier directory")
|
||||
repo_root, barrier_dir = (Path(value) for value in sys.argv[1:])
|
||||
sys.path.insert(0, str(repo_root))
|
||||
import server
|
||||
from api import routes
|
||||
|
||||
ready = barrier_dir / "ready"
|
||||
release = barrier_dir / "release"
|
||||
original = routes._sse_with_id
|
||||
|
||||
def terminal_barrier(handler, event, data, event_id=None):
|
||||
if event == "done" and handler.path.startswith("/api/chat/stream?"):
|
||||
ready.touch()
|
||||
deadline = time.monotonic() + 15
|
||||
while not release.exists():
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("test did not release the terminal HTTP frame")
|
||||
time.sleep(0.005)
|
||||
return original(handler, event, data, event_id)
|
||||
|
||||
routes._sse_with_id = terminal_barrier
|
||||
server.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -599,20 +599,12 @@ def test_session_model_parent_session_id():
|
||||
|
||||
|
||||
def test_session_compact_includes_parent():
|
||||
"""Verify compact() includes parent_session_id."""
|
||||
src = _read('api/models.py')
|
||||
# Find the compact method and scan its full body for parent_session_id.
|
||||
# PR #1591 (May 2026) added a has_pending_user_message recompute block at
|
||||
# the top of compact() which pushed the parent_session_id field beyond a
|
||||
# 1500-char window — widen the scan to 3000 chars to cover the full
|
||||
# return-dict body without re-tightening every time compact() grows.
|
||||
# compact() accepts optional projection flags on separate lines; match the
|
||||
# method name and first parameter without pinning formatting.
|
||||
compact_def_match = re.search(r"def compact\(\s*self", src)
|
||||
assert compact_def_match, "Could not find compact() method"
|
||||
snippet = src[compact_def_match.start():compact_def_match.start() + 3000]
|
||||
assert "'parent_session_id'" in snippet, \
|
||||
"compact() should include parent_session_id"
|
||||
"""Verify compact() exposes a fork's parent and omits it for root sessions."""
|
||||
fork = routes.Session(session_id="fork-1", parent_session_id="parent-1")
|
||||
assert fork.compact()["parent_session_id"] == "parent-1"
|
||||
|
||||
root = routes.Session(session_id="root-1")
|
||||
assert "parent_session_id" not in root.compact()
|
||||
|
||||
|
||||
def test_session_metadata_fields_includes_parent():
|
||||
|
||||
@@ -93,6 +93,9 @@ eval(extractFunc('_collapseSessionLineageForSidebar'));
|
||||
eval(extractFunc('_attachChildSessionsToSidebarRows'));
|
||||
eval(extractFunc('_sessionAttentionState'));
|
||||
eval(extractFunc('_sidebarRowHasVisibleMessages'));
|
||||
eval(extractFunc('_isDelegatedSubagentRow'));
|
||||
eval(extractFunc('_sidebarProjectResolver'));
|
||||
eval(extractFunc('_sidebarRowsById'));
|
||||
eval(extractFunc('_partitionSidebarSessionRows'));
|
||||
eval(extractFunc('_scopedSidebarReferenceRows'));
|
||||
eval(extractFunc('_renderSidebarRowsFromRawSessions'));
|
||||
@@ -216,9 +219,9 @@ console.log(JSON.stringify({
|
||||
}));
|
||||
"""
|
||||
out = json.loads(_run_node(source))
|
||||
# Child survives the visibility/project scope into sessionsRaw (parent does not)...
|
||||
assert out["sessionsRaw"] == ["subagent_child"]
|
||||
# ...but is NOT rendered as a top-level orphan.
|
||||
# The child inherits its parent's project, so the "no project" filter drops both...
|
||||
assert out["sessionsRaw"] == []
|
||||
# ...and it is NOT rendered as a top-level orphan.
|
||||
assert out["topLevel"] == []
|
||||
assert out["orphans"] == []
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""#7569 review fix: timed-flush must not leave the folded index stale.
|
||||
|
||||
The ~10 Hz reasoning throttle in the stream callback clears the coalescing
|
||||
buffer before emitting:
|
||||
|
||||
put('reasoning', {'text': _reasoning_buffer[0]})
|
||||
_reasoning_buffer[0] = ''
|
||||
# ← _reasoning_buffer_index.reset() was missing here
|
||||
|
||||
``_CompactEchoIndex`` records raw offsets *relative to the buffer's accumulated
|
||||
length*. Clearing the buffer without resetting the index leaves it describing
|
||||
text that is no longer there, so the next ``cut_to`` returns a raw offset that
|
||||
no longer corresponds to the buffer — the live-echo strip then truncates the
|
||||
buffer at a phantom position and falsely marks the interim as
|
||||
``reasoning_echo: true`` (leaks a truncated copy into both the SSE stream and
|
||||
the durable journal).
|
||||
|
||||
This file pins the invariant at the *call site*, not just on the index class:
|
||||
revert the fix commit and the first test fails.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
REPO = pathlib.Path(__file__).resolve().parent.parent
|
||||
STREAMING = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _timed_flush_block() -> str:
|
||||
"""The ``if now - _reasoning_last_put[0] >= 0.1:`` throttle branch body."""
|
||||
match = re.search(
|
||||
r"if now - _reasoning_last_put\[0\] >= 0\.1:\n(.*?)(?=\n\s{16}(?:# Track reasoning deltas|_metering_reasoning_deltas))",
|
||||
STREAMING,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match, "timed-flush throttle branch not found in api/streaming.py"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_timed_flush_clears_index_together_with_the_buffer():
|
||||
"""Clearing the reasoning buffer must also reset the folded index, in the
|
||||
same throttle branch, after the clear (ordering matters: the reset cannot
|
||||
sit in an unrelated branch or before the clear)."""
|
||||
block = _timed_flush_block()
|
||||
|
||||
assert "_reasoning_buffer[0] = ''" in block, (
|
||||
"timed-flush branch must clear the coalesced reasoning buffer"
|
||||
)
|
||||
assert "_reasoning_buffer_index.reset()" in block, (
|
||||
"timed-flush branch must reset the folded index when it clears the "
|
||||
"buffer — leaving the index stale makes later cut_to() offsets "
|
||||
"describe text that is no longer in the buffer (#7569 re-gate)"
|
||||
)
|
||||
# reset must come AFTER the clear, and be at the same indent depth (8
|
||||
# spaces inside the throttle branch), not in a sibling branch.
|
||||
clear_pos = block.index("_reasoning_buffer[0] = ''")
|
||||
reset_pos = block.index("_reasoning_buffer_index.reset()")
|
||||
assert reset_pos > clear_pos, (
|
||||
"reset must follow the buffer clear, so the next append starts from "
|
||||
"an empty raw-length base"
|
||||
)
|
||||
assert re.search(r"^\s{20}_reasoning_buffer_index\.reset\(\)", block, re.M), (
|
||||
"reset must be inside the timed-flush branch (deeper than the clear), "
|
||||
"not in an outer/sibling scope"
|
||||
)
|
||||
|
||||
|
||||
def test_all_buffer_clears_are_index_safe():
|
||||
"""Every site that zeroes ``_reasoning_buffer[0]`` must reset the folded
|
||||
index immediately after — grep the codebase for clear sites and require
|
||||
each to be paired with the index reset."""
|
||||
for clear_match in re.finditer(r"_reasoning_buffer\[0\] = ''", STREAMING):
|
||||
window = STREAMING[clear_match.start(): clear_match.start() + 400]
|
||||
reset_present = re.search(
|
||||
r"_reasoning_buffer_index\.reset\(\)", window
|
||||
)
|
||||
assert reset_present, (
|
||||
"a buffer clear at api/streaming.py has no adjacent "
|
||||
"_reasoning_buffer_index.reset() — stale offsets would corrupt "
|
||||
"the next cut_to() call"
|
||||
)
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Behavioral tests for #7628 review round: /compress paging restore + suffix
|
||||
strategy for repeated identical turns.
|
||||
|
||||
Round-3 review (nesquena-hermes 2026-09-19) found two data-integrity blockers:
|
||||
|
||||
1. [CORE] A successful /compress returns the FULL transcript, but
|
||||
`_applyManualCompressionResult` did not restore `_messagesTruncated` /
|
||||
`_oldestIdx` — the bounded preflight's values (e.g. truncated=true,
|
||||
oldestIdx=70) survived, producing a false "70 older messages" affordance
|
||||
and shifting every absolute transcript index by 70.
|
||||
|
||||
2. [SILENT] `_restoreSettledSession` preservation used
|
||||
`(_stagedMatchesCurrentPrefix || _stagedMatchesCurrentSuffix)`, so when
|
||||
identical repeated turns made BOTH comparisons succeed the prefix branch
|
||||
spliced at the wrong offset — silently dropping and duplicating rows.
|
||||
The server truncation signal now decides: truncated => suffix-exclusive.
|
||||
|
||||
Node-backed tests extract the real functions from static/ and drive them
|
||||
against a stub DOM (same harness pattern as test_issue5224).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
_COMMANDS_JS = _ROOT / "static" / "commands.js"
|
||||
_MESSAGES_JS = _ROOT / "static" / "messages.js"
|
||||
_SESSIONS_JS = _ROOT / "static" / "sessions.js"
|
||||
_NODE = shutil.which("node")
|
||||
|
||||
pytestmark = pytest.mark.skipif(_NODE is None, reason="node is required to execute message runtime tests")
|
||||
|
||||
|
||||
_DRIVER = r"""
|
||||
const fs = require('fs');
|
||||
const commandsSrc = fs.readFileSync(process.argv[2], 'utf8');
|
||||
const messagesSrc = fs.readFileSync(process.argv[3], 'utf8');
|
||||
const sessionsSrc = fs.readFileSync(process.argv[4], 'utf8');
|
||||
const scenario = JSON.parse(process.argv[5] || '{}');
|
||||
|
||||
function extractFunction(source, name) {
|
||||
const markers = [`async function ${name}(`, `function ${name}(${name}`, `function ${name}(`, `${name}=async(`, `${name}=async (`];
|
||||
let start = -1;
|
||||
for (const marker of markers) {
|
||||
start = source.indexOf(marker);
|
||||
if (start >= 0) break;
|
||||
}
|
||||
if (start < 0) throw new Error(`missing ${name}`);
|
||||
let i = source.indexOf('{', start);
|
||||
if (i < 0) throw new Error(`missing body for ${name}`);
|
||||
let depth = 1; i++;
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
if (ch === '{') depth++;
|
||||
if (ch === '}') { depth--; if (depth === 0) return source.slice(start, i + 1); }
|
||||
i++;
|
||||
}
|
||||
throw new Error(`unterminated body for ${name}`);
|
||||
}
|
||||
|
||||
function install(name, src) {
|
||||
const body = extractFunction(src, name);
|
||||
// Drop the `async function name(` or `function name(` prefix, keep body.
|
||||
const eq = body.indexOf('{');
|
||||
const inner = body.slice(eq);
|
||||
globalThis[name] = new Function(`return (${name === 'jumpToSessionStart' ? 'async function' : 'function'} ${inner})`)();
|
||||
}
|
||||
|
||||
// ---- helpers from messages.js (mirror test_issue5224 set) ----
|
||||
const MESSAGE_HELPERS = [
|
||||
"_isMarkerOnlyAssistantMessage", "_streamRecoveryControlMessageText",
|
||||
"_streamRecoveryControlMessage", "_filterRecoveryControlMessages",
|
||||
"_replaceMarkerOnlyAssistantWithStreamError", "_messageIdentityKey",
|
||||
"_isHistoricalAnchorActivityScene", "_carryForwardEphemeralTurnFields",
|
||||
"_isTerminalStreamErrorMarkerMessage", "_restoreSettledSession",
|
||||
"_mergeSettledToolCallsWithLiveMetadata",
|
||||
];
|
||||
for (const h of MESSAGE_HELPERS) {
|
||||
const body = extractFunction(messagesSrc, h);
|
||||
const factory = new Function(`${body}; return ${h};`);
|
||||
globalThis[h] = factory();
|
||||
}
|
||||
|
||||
// ---- compress result applier from commands.js ----
|
||||
const COMPRESS_BODY = extractFunction(commandsSrc, '_applyManualCompressionResult');
|
||||
// Rebuild as a normal function (it contains `await` – keep async).
|
||||
globalThis._applyManualCompressionResult = new Function(
|
||||
'return (' + COMPRESS_BODY + ')'
|
||||
)();
|
||||
|
||||
function runtimeStubs() {
|
||||
const calls = [];
|
||||
globalThis.S = { session: { session_id: 's1' }, messages: [], toolCalls: [] };
|
||||
globalThis.activeSid = 's1';
|
||||
globalThis.streamId = 'stream-1';
|
||||
globalThis.assistantText = false;
|
||||
globalThis._messagesTruncated = !!scenario.prestateTruncated;
|
||||
globalThis._oldestIdx = scenario.prestateOldestIdx || 0;
|
||||
globalThis._messagesGeneration = 1;
|
||||
globalThis._loadingOlder = false;
|
||||
globalThis._loadingSessionId = null;
|
||||
globalThis._messageRenderWindowSize = 50;
|
||||
globalThis._msgLimitMax = scenario.msgLimitMax || 200;
|
||||
globalThis._INITIAL_MSG_LIMIT = 50;
|
||||
globalThis._EPHEMERAL_TURN_FIELDS = ['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard','_anchor_stream_id','_anchor_activity_scene'];
|
||||
globalThis._isActiveSession = () => scenario.isActiveSession !== false;
|
||||
globalThis._isSessionCurrentPane = () => scenario.isSessionCurrentPane !== false;
|
||||
globalThis._isSessionActivelyViewed = () => !!scenario.isSessionActivelyViewed;
|
||||
globalThis._closeSource = () => calls.push('closeSource');
|
||||
globalThis._clearStreamEndRecovery = () => calls.push('clearStreamEndRecovery');
|
||||
globalThis._clearOwnerInflightState = () => calls.push('clearOwnerInflight');
|
||||
globalThis.clearLiveToolCards = () => calls.push('clearLiveToolCards');
|
||||
globalThis.removeThinking = () => calls.push('removeThinking');
|
||||
globalThis._flushReasoningToAnchor = () => calls.push('flushReasoning');
|
||||
globalThis._applyToAnchor = () => calls.push('applyToAnchor');
|
||||
globalThis._hydrateTodosFromSession = () => calls.push('hydrateTodos');
|
||||
globalThis._scheduleAnchorRegistryCleanup = () => calls.push('scheduleAnchorRegistryCleanup');
|
||||
globalThis._smdEndParser = () => calls.push('smdEndParser');
|
||||
globalThis._markSessionCompletionUnread = () => calls.push('markCompletionUnread');
|
||||
globalThis._markSessionViewed = () => calls.push('markSessionViewed');
|
||||
globalThis.localStorage = { setItem: () => calls.push('setLocalStorageItem'), getItem: () => null, removeItem: () => calls.push('removeLocalStorageItem') };
|
||||
globalThis._setActiveSessionUrl = () => calls.push('setActiveSessionUrl');
|
||||
globalThis.showToast = () => calls.push('showToast');
|
||||
globalThis._clearApprovalForOwner = () => calls.push('clearApprovalForOwner');
|
||||
globalThis._clearClarifyForOwner = () => calls.push('clearClarifyForOwner');
|
||||
globalThis._streamFadeCleanupReduceMotionListener = () => calls.push('streamFadeCleanup');
|
||||
globalThis._cancelThrottledSnapshotTimer = () => calls.push('cancelThrottledSnapshot');
|
||||
globalThis._clearAnchorProseIncrementalNode = () => calls.push('clearAnchorProse');
|
||||
globalThis._cancelAnimationFramePendingStreamRender = () => calls.push('cancelRaf');
|
||||
globalThis.finalizeThinkingCard = () => calls.push('finalizeThinkingCard');
|
||||
globalThis.syncTopbar = () => calls.push('syncTopbar');
|
||||
globalThis.renderMessages = () => calls.push('renderMessages');
|
||||
globalThis.renderSessionList = () => calls.push('renderSessionList');
|
||||
globalThis.updateQueueBadge = () => calls.push('updateQueueBadge');
|
||||
globalThis._setActivePaneIdleIfOwner = () => calls.push('setActivePaneIdle');
|
||||
globalThis.setBusy = () => calls.push('setBusy');
|
||||
globalThis.setComposerStatus = () => calls.push('setComposerStatus');
|
||||
globalThis.setCompressionUi = () => calls.push('setCompressionUi');
|
||||
globalThis.setStatus = () => calls.push('setStatus');
|
||||
globalThis.msgContent = (m) => (m && m.content && typeof m.content === 'string' ? m.content : '');
|
||||
globalThis._isContextCompactionMessage = () => false;
|
||||
globalThis._setCompressionSessionLock = () => calls.push('setCompressionSessionLock');
|
||||
globalThis._messageRenderableMessageCount = () => scenario.messageRenderableCount || 50;
|
||||
globalThis._currentMessageRenderWindowSize = () => scenario.currentWindowSize || 12;
|
||||
globalThis._restoreMessageRenderWindowAfterSettledRender = () => calls.push('restoreRenderWindow');
|
||||
globalThis.projectSessionArtifactsForOwner = () => calls.push('projectArtifacts');
|
||||
globalThis._streamFinalized = false;
|
||||
globalThis._persistTimer = null;
|
||||
globalThis._attachProjectedAnchorSceneToLastAssistant = () => calls.push('attachProjected');
|
||||
globalThis._isMessagePaneNearBottom = () => true;
|
||||
globalThis._isMessageReaderUnpinned = () => false;
|
||||
globalThis._queueDrainSid = null;
|
||||
// reducer used by _restoreSettledSession (mirrors live code)
|
||||
return calls;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const calls = runtimeStubs();
|
||||
|
||||
if (scenario.action === 'compress_restores_paging') {
|
||||
// Pre-state: bounded preflight left truncated=true, oldestIdx=70.
|
||||
globalThis._messagesTruncated = true;
|
||||
globalThis._oldestIdx = 70;
|
||||
const fullRows = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
fullRows.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `row-${i}`, _ts: `ts-${i}` });
|
||||
}
|
||||
const payload = {
|
||||
session: {
|
||||
session_id: 's1',
|
||||
message_count: 100,
|
||||
messages: fullRows,
|
||||
tool_calls: [],
|
||||
_messages_truncated: false,
|
||||
_messages_offset: 0,
|
||||
},
|
||||
};
|
||||
await globalThis._applyManualCompressionResult(payload, '', 100, '/compress');
|
||||
console.log(JSON.stringify({
|
||||
action: 'compress_restores_paging',
|
||||
messagesTruncated: !!globalThis._messagesTruncated,
|
||||
oldestIdx: globalThis._oldestIdx,
|
||||
messageCountAfter: Array.isArray(S.messages) ? S.messages.length : -1,
|
||||
calls,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (scenario.action === 'settle_repeated_turns') {
|
||||
// Bounded settle against a transcript containing repeated identical turns
|
||||
// (same role/content but distinct _ids). Round-2 bug: BOTH prefix and
|
||||
// suffix matched, prefix won, and rows were dropped+duplicated.
|
||||
const state = scenario.state || {};
|
||||
globalThis.S.session = { session_id: 's1', message_count: state.messageCount };
|
||||
globalThis.S.messages = state.messages || [];
|
||||
globalThis.streamId = 'stream-7628';
|
||||
globalThis.S.activeStreamId = 'stream-7628';
|
||||
globalThis.api = async () => scenario.apiPayload || { session: null };
|
||||
const status = await globalThis._restoreSettledSession({}, { status: true, preserveVisibleOnShorterTerminalSnapshot: true });
|
||||
const msgs = Array.isArray(globalThis.S.messages) ? globalThis.S.messages : [];
|
||||
console.log(JSON.stringify({
|
||||
action: 'settle_repeated_turns',
|
||||
status,
|
||||
messages: msgs.map((m) => ({ role: m.role, content: m.content, _ts: m._ts, _id: m._id || null })),
|
||||
oldestIdx: globalThis._oldestIdx,
|
||||
messagesTruncated: !!globalThis._messagesTruncated,
|
||||
terminalMarkerCount: msgs.filter(globalThis._isTerminalStreamErrorMarkerMessage).length,
|
||||
calls,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (scenario.action === 'load_older_paging') {
|
||||
// After a bounded recovery (offset=70), "Load earlier" must request a page
|
||||
// keyed off _oldestIdx (no gap and no overlap), and merge must close the
|
||||
// window: final oldestIdx === requested offset.
|
||||
globalThis._messagesTruncated = true;
|
||||
globalThis._oldestIdx = scenario.oldestIdx || 70;
|
||||
const existing = [];
|
||||
for (let i = 70; i < 100; i++) existing.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `row-${i}`, _ts: `ts-${i}` });
|
||||
globalThis.S.messages = existing;
|
||||
globalThis.S.session = { session_id: 's1', message_count: 100 };
|
||||
const requested = [];
|
||||
globalThis.api = async (url, opts) => {
|
||||
requested.push(String(url));
|
||||
// Return one older page of size 50 (rows 20..69).
|
||||
const older = [];
|
||||
for (let i = 20; i < 70; i++) older.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `row-${i}`, _ts: `ts-${i}` });
|
||||
return {
|
||||
session: { session_id: 's1', message_count: 100, messages: older, _messages_truncated: true, _messages_offset: 20 },
|
||||
};
|
||||
};
|
||||
// Extract the live loader from sessions.js.
|
||||
const loaderBody = extractFunction(sessionsSrc, '_loadOlderMessages');
|
||||
const loaderFactory = new Function(`${loaderBody}; return _loadOlderMessages;`);
|
||||
const loader = loaderFactory();
|
||||
await loader.call(globalThis);
|
||||
console.log(JSON.stringify({
|
||||
action: 'load_older_paging',
|
||||
requested,
|
||||
messagesTruncated: !!globalThis._messagesTruncated,
|
||||
oldestIdx: globalThis._oldestIdx,
|
||||
messagesLen: Array.isArray(S.messages) ? S.messages.length : -1,
|
||||
loadingOlder: !!globalThis._loadingOlder,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`unknown action: ${scenario.action}`);
|
||||
})().catch((err) => {
|
||||
console.error(err && err.stack ? err.stack : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def driver_path(tmp_path_factory):
|
||||
driver = tmp_path_factory.mktemp("7628_driver") / "driver.js"
|
||||
driver.write_text(_DRIVER, encoding="utf-8")
|
||||
return str(driver)
|
||||
|
||||
|
||||
def _run_scenario(driver_path: str, scenario: dict) -> dict:
|
||||
command = [_NODE, driver_path, str(_COMMANDS_JS), str(_MESSAGES_JS), str(_SESSIONS_JS), json.dumps(scenario)]
|
||||
result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", check=False, timeout=30)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"node driver failed: {result.stderr}")
|
||||
return json.loads(result.stdout.strip())
|
||||
|
||||
|
||||
def test_compress_success_restores_paging_state(driver_path):
|
||||
"""A successful /compress returning the full transcript must reset the
|
||||
bounded preflight's paging signals before render (#7628 blocker 1)."""
|
||||
outcome = _run_scenario(driver_path, {"action": "compress_restores_paging"})
|
||||
assert outcome["messagesTruncated"] is False, "compress success must clear _messagesTruncated"
|
||||
assert outcome["oldestIdx"] == 0, f"compress success must reset _oldestIdx to 0, got {outcome['oldestIdx']}"
|
||||
assert outcome["messageCountAfter"] == 100
|
||||
|
||||
|
||||
def test_settle_bounded_preserves_no_drop_no_dup(driver_path):
|
||||
"""A bounded settle against repeated identical turns must not drop or
|
||||
duplicate rows (#7628 blocker 2, exact reviewer reproduction).
|
||||
|
||||
All user rows share one identity key (same role/content/_ts), all assistant
|
||||
rows share one too — _messageIdentityKey collides across every pair. A
|
||||
bounded tail [u3,a3,u2,a2] therefore matches BOTH the visible prefix
|
||||
(u1,a1,u2,a2…) and the visible suffix (…u2,a2) at key level. The round-2
|
||||
`||` code preferred the prefix branch: splice offset = staged.length
|
||||
dropped u1/a1 and re-emitted u3/a3 (duplicate). The server truncation
|
||||
signal must force suffix-exclusive splicing.
|
||||
"""
|
||||
_u = {"role": "user", "content": "What's the status?", "_ts": "t-u"}
|
||||
_a = {"role": "assistant", "content": "All good.", "_ts": "t-a"}
|
||||
# Visible before settle: u1,a1,u2,a2,u3,a3 + Connection-interrupted marker.
|
||||
state_messages = [
|
||||
{**_u, "_id": 1}, {**_a, "_id": 2},
|
||||
{**_u, "_id": 3}, {**_a, "_id": 4},
|
||||
{**_u, "_id": 5}, {**_a, "_id": 6},
|
||||
{"role": "assistant", "content": "**Connection interrupted:** The browser lost the live SSE connection before the response finished.", "_ts": "t-err", "_id": 7},
|
||||
]
|
||||
outcome = _run_scenario(driver_path, {
|
||||
"action": "settle_repeated_turns",
|
||||
"isSessionCurrentPane": True,
|
||||
"state": {
|
||||
"messageCount": 12,
|
||||
"messages": state_messages,
|
||||
},
|
||||
"apiPayload": {
|
||||
"session": {
|
||||
"session_id": "s1",
|
||||
"active_stream_id": None,
|
||||
"pending_user_message": None,
|
||||
"message_count": 12,
|
||||
"messages": [
|
||||
# Reviewer's tail: [u3,a3,u2,a2] — identity keys collide
|
||||
# with u1/a1/u2/a2/u3/a3 above, so prefix AND suffix both
|
||||
# compare equal.
|
||||
{**_u, "_id": 8}, {**_a, "_id": 9},
|
||||
{**_u, "_id": 10}, {**_a, "_id": 11},
|
||||
],
|
||||
"_messages_truncated": True,
|
||||
"_messages_offset": 4,
|
||||
},
|
||||
},
|
||||
})
|
||||
assert outcome["status"] == "restored", outcome
|
||||
ids = [m["_id"] for m in outcome["messages"]]
|
||||
# No dropped id, no dup id (the prefix-wins bug duplicated u3/a3 and
|
||||
# dropped u1/a1 — every retained row must be unique).
|
||||
assert len(ids) == len(set(ids)), f"duplicate rows survived settle: {ids}"
|
||||
# Staged (authoritative) rows must all be present, plus the marker once.
|
||||
assert set(ids) == {8, 9, 10, 11, 7}, f"unexpected transcript after fix: {ids}"
|
||||
# Identity (role|_ts|content) counts: staged rows legitimately repeat
|
||||
# (two identical user pairs in the tail), but the bug added a THIRD copy by
|
||||
# splicing the visible tail back in. user<=2, assistant<=2, marker exactly 1.
|
||||
keys = [(m["role"], m["_ts"], m["content"]) for m in outcome["messages"]]
|
||||
kc = {}
|
||||
for k in keys:
|
||||
kc[k] = kc.get(k, 0) + 1
|
||||
user_key = ("user", "t-u", "What's the status?")
|
||||
asst_key = ("assistant", "t-a", "All good.")
|
||||
assert kc.get(user_key, 0) == 2, f"user rows must appear exactly twice (staged), got {kc.get(user_key, 0)}: {keys}"
|
||||
assert kc.get(asst_key, 0) == 2, f"assistant rows must appear exactly twice (staged), got {kc.get(asst_key, 0)}: {keys}"
|
||||
assert outcome["terminalMarkerCount"] == 1, outcome
|
||||
assert outcome["oldestIdx"] == 4, f"offset must follow server, got {outcome['oldestIdx']}"
|
||||
assert outcome["messagesTruncated"] is True
|
||||
|
||||
|
||||
def test_bounded_recovery_prepares_gap_free_load_earlier(driver_path):
|
||||
"""After a bounded recovery (oldestIdx=70), Load Earlier must page from the
|
||||
authoritative offset so the fetched range has no gap and no overlap
|
||||
(#7628)."""
|
||||
outcome = _run_scenario(driver_path, {"action": "load_older_paging", "oldestIdx": 70, "msgLimitMax": 80})
|
||||
assert outcome["requested"], "loader must have issued an api call"
|
||||
url = outcome["requested"][0]
|
||||
assert "msg_before=70" in url, f"load-earlier must page from authoritative offset: {url}"
|
||||
assert "msg_limit=" in url, url
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Behavioural regressions for the #7628 review round-3 findings.
|
||||
|
||||
The round-3 gate reproduced two data-integrity defects against the shipped
|
||||
functions and asked for tests that reach them (the source-assertion suite
|
||||
stayed green through both reproductions):
|
||||
|
||||
1. **A successful `/compress` kept the bounded preflight's paging state.**
|
||||
The success path assigned the full-transcript response without restoring
|
||||
``_messagesTruncated`` / ``_oldestIdx``, so after a compression that
|
||||
returned 100 full rows the client still showed "70 older messages" and
|
||||
every absolute transcript index was off by 70 — which is what edit /
|
||||
fork / regenerate address rows by.
|
||||
|
||||
2. **Prefix match won over suffix match when both succeeded.** A bounded
|
||||
settle returns a *suffix* of the durable transcript, but the old
|
||||
``prefix || suffix`` expression preferred the prefix branch, so a
|
||||
transcript with repeated identical turns spliced at the wrong offset:
|
||||
``u1/a1`` silently dropped, ``u3/a3`` duplicated.
|
||||
|
||||
These tests execute the real decision code rather than grepping it: the
|
||||
splice strategy block is extracted from ``static/messages.js`` verbatim and
|
||||
driven through a Node VM with the paging signals as the only variable, and
|
||||
the compress success path is asserted at source level for the restore
|
||||
ordering (that block issues a live ``/api/session`` fetch, so driving it
|
||||
would need a network).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.js_source_extract import extract_function
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
COMMANDS_JS = (ROOT / "static" / "commands.js").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
|
||||
|
||||
# ── extraction helpers ───────────────────────────────────────────────────────
|
||||
|
||||
_SPLICE_DEPS = [
|
||||
"_messageIdentityKey",
|
||||
"_isTerminalStreamErrorMarkerMessage",
|
||||
"_streamRecoveryControlMessage",
|
||||
"_filterRecoveryControlMessages",
|
||||
"_carryForwardEphemeralTurnFields",
|
||||
]
|
||||
|
||||
|
||||
def _splice_block() -> str:
|
||||
"""The bounded-settle splice decision, verbatim from messages.js.
|
||||
|
||||
Spans the terminal-marker detection and the prefix/suffix comparisons
|
||||
through the ``S.messages`` assignment — the region the round-3 gate
|
||||
patched. Everything before it (paging restore, session assignment) is set
|
||||
up as harness state instead.
|
||||
"""
|
||||
start_marker = "const _currentVisibleEndsWithTerminalMarker=("
|
||||
start = MESSAGES_JS.find(start_marker)
|
||||
assert start >= 0, "terminal-marker detection not found in static/messages.js"
|
||||
end_marker = "S.messages=_filterRecoveryControlMessages(_resolvedMessages || []);"
|
||||
end = MESSAGES_JS.find(end_marker, start)
|
||||
assert end >= 0, "S.messages assignment after the splice not found"
|
||||
return MESSAGES_JS[start : end + len(end_marker)]
|
||||
|
||||
|
||||
def _dependency_functions() -> list[str]:
|
||||
"""The splice block's transitive pure dependencies, in definition order.
|
||||
|
||||
The extraction is closure-driven: start from the helpers the block names,
|
||||
then keep pulling the helpers those helpers reference until the set is
|
||||
closed. That keeps the harness honest when the production code grows a
|
||||
new dependency — a missing one fails the node run instead of silently
|
||||
stubbing behaviour away.
|
||||
"""
|
||||
import re
|
||||
|
||||
needed: list[str] = list(_SPLICE_DEPS)
|
||||
resolved: list[str] = []
|
||||
seen: set[str] = set()
|
||||
while needed:
|
||||
name = needed.pop(0)
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
try:
|
||||
body = extract_function(MESSAGES_JS, name)
|
||||
except AssertionError:
|
||||
continue
|
||||
resolved.append(name)
|
||||
for ref in re.findall(r"\b(_[a-zA-Z][a-zA-Z0-9_]*)\s*\(", body):
|
||||
if ref not in seen and ref != name:
|
||||
needed.append(ref)
|
||||
# Definition order matters only for hoisted `function` declarations, which
|
||||
# JS hoists anyway; keep the resolved order for readability.
|
||||
return [extract_function(MESSAGES_JS, name) for name in resolved]
|
||||
|
||||
|
||||
def _compress_success_block() -> str:
|
||||
"""The `/compress` success path that assigns the full-transcript response.
|
||||
|
||||
Anchored on the bounded preflight fetch's continuation: the block runs
|
||||
after a successful compression response and must restore the paging
|
||||
signals before ``renderMessages()``.
|
||||
"""
|
||||
anchor = "const summary=data&&data.summary;"
|
||||
start = COMMANDS_JS.rfind(anchor)
|
||||
assert start >= 0, "compress success block not found in static/commands.js"
|
||||
assign = COMMANDS_JS.rfind("S.messages=", 0, start)
|
||||
assert assign >= 0, "S.messages assignment before the summary block not found"
|
||||
return COMMANDS_JS[assign:start]
|
||||
|
||||
|
||||
# ── node harness ─────────────────────────────────────────────────────────────
|
||||
|
||||
_HARNESS = r"""
|
||||
const deps = __DEPS__;
|
||||
const splice = __SPLICE__;
|
||||
|
||||
const S = { session: { session_id: 'sid' }, messages: [] };
|
||||
let _messagesTruncated = false;
|
||||
let _oldestIdx = 0;
|
||||
let _adoptRegenerationRevision = () => {};
|
||||
let _hydrateTodosFromSession = () => {};
|
||||
let _markSessionCompletionUnread = () => {};
|
||||
let _setActiveSessionUrl = () => {};
|
||||
let _attachProjectedAnchorSceneToLastAssistant = () => {};
|
||||
let _replaceMarkerOnlyAssistantWithStreamError = () => false;
|
||||
const store = {};
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = String(v); },
|
||||
};
|
||||
// Closure-scope constant that lives just above the extracted helper in
|
||||
// messages.js (extract_function only captures the function body).
|
||||
const _EPHEMERAL_TURN_FIELDS = ['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard','_anchor_stream_id','_anchor_activity_scene'];
|
||||
for (const fn of deps) eval(fn);
|
||||
|
||||
// Scenario: repeated identical turns in the visible window, a terminal
|
||||
// marker at the end, and a bounded durable tail whose ids ALSO appear
|
||||
// earlier — so both the prefix and the suffix comparison succeed.
|
||||
function makeTurn(n) {
|
||||
return [
|
||||
{ role: 'user', id: `u-${n}`, content: `prompt ${n}` },
|
||||
{ role: 'assistant', id: `a-${n}`, content: `answer ${n}` },
|
||||
];
|
||||
}
|
||||
S.messages = [];
|
||||
for (const n of [1, 2, 3]) S.messages.push(...makeTurn(n));
|
||||
// The real terminal marker shape: an assistant row whose content starts with
|
||||
// the Connection-interrupted prefix (what _isTerminalStreamErrorMarkerMessage
|
||||
// actually matches).
|
||||
S.messages.push({
|
||||
role: 'assistant',
|
||||
id: 'marker',
|
||||
content: '**Connection interrupted:** The browser lost the live SSE connection before the response finished.',
|
||||
});
|
||||
|
||||
const durableTail = [
|
||||
{ role: 'user', id: 'u-3', content: 'prompt 3' },
|
||||
{ role: 'assistant', id: 'a-3', content: 'answer 3' },
|
||||
];
|
||||
const session = {
|
||||
session_id: 'sid',
|
||||
messages: durableTail,
|
||||
_messages_truncated: __TRUNCATED__,
|
||||
_messages_offset: 4,
|
||||
};
|
||||
_messagesTruncated = __TRUNCATED__;
|
||||
_oldestIdx = __OFFSET__;
|
||||
|
||||
// The state the splice block reads, mirroring the lines just above it.
|
||||
const _currentMessages = S.messages.slice();
|
||||
const _currentVisibleMessages = _filterRecoveryControlMessages(_currentMessages);
|
||||
const _stagedMessages = _carryForwardEphemeralTurnFields(_currentMessages, session.messages);
|
||||
const preserveVisibleOnShorterTerminalSnapshot = true;
|
||||
S.session = session;
|
||||
|
||||
eval(splice);
|
||||
|
||||
const out = {
|
||||
truncated: _messagesTruncated,
|
||||
oldestIdx: _oldestIdx,
|
||||
rows: (S.messages || []).map(m => `${m.role}:${m.id}`),
|
||||
};
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _run_node(script: str) -> dict:
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", script],
|
||||
cwd=str(ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"node subprocess failed:\n--- stdout ---\n{result.stdout}\n"
|
||||
f"--- stderr ---\n{result.stderr}"
|
||||
)
|
||||
return json.loads(result.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def _drive(*, truncated: bool, offset: int) -> dict:
|
||||
deps = "[" + ",".join(json.dumps(f) for f in _dependency_functions()) + "]"
|
||||
splice = json.dumps(_splice_block())
|
||||
script = (
|
||||
_HARNESS.replace("__DEPS__", deps)
|
||||
.replace("__SPLICE__", splice)
|
||||
.replace("__TRUNCATED__", "true" if truncated else "false")
|
||||
.replace("__OFFSET__", str(offset))
|
||||
)
|
||||
return _run_node(script)
|
||||
|
||||
|
||||
# ── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bounded_settle_with_repeated_identical_turns_drops_and_duplicates_nothing():
|
||||
"""The round-3 SILENT finding: identical repeated turns must not splice wrong.
|
||||
|
||||
The visible window holds repeated identical turns, so the durable tail's
|
||||
ids appear at BOTH the head and the tail of the window and both the
|
||||
prefix and the suffix comparison succeed. The pre-fix
|
||||
``prefix || suffix`` expression preferred the prefix branch and spliced at
|
||||
the head offset — dropping ``u2/a2`` and ``u3/a3`` from the window even
|
||||
though the server returned them as a truncated suffix (``_oldestIdx=4``).
|
||||
The truncation signal must select the suffix branch instead.
|
||||
|
||||
The two branches produce observably different row lists on this shape,
|
||||
which is what makes this a regression test rather than a tautology: with
|
||||
the prefix branch restored the splice keeps the wrong rows.
|
||||
"""
|
||||
out = _drive(truncated=True, offset=4)
|
||||
|
||||
rows = out["rows"]
|
||||
# The durable tail rows are present exactly once each — the pre-fix
|
||||
# prefix branch duplicated them.
|
||||
assert rows.count("user:u-3") == 1, rows
|
||||
assert rows.count("assistant:a-3") == 1, rows
|
||||
# No row appears twice anywhere.
|
||||
assert len(rows) == len(set(rows)), f"duplicate rows in {rows}"
|
||||
# The suffix splice keeps the durable tail plus the rows that follow it
|
||||
# in the visible window (the terminal marker). The prefix branch would
|
||||
# splice at the head offset and re-emit u2/a2/u3/a3.
|
||||
assert rows == ["user:u-3", "assistant:a-3", "assistant:marker"], (
|
||||
"the suffix splice must keep durable-tail + the rows after it; got "
|
||||
f"{rows} (extra rows mean the prefix branch won)"
|
||||
)
|
||||
# Paging follows the response so absolute indices line up.
|
||||
assert out["truncated"] is True
|
||||
assert out["oldestIdx"] == 4
|
||||
|
||||
|
||||
def test_bounded_settle_keeps_the_response_paging_signals():
|
||||
"""Paging flags follow the server's truncation signal, not the preflight."""
|
||||
out = _drive(truncated=True, offset=4)
|
||||
assert out["truncated"] is True
|
||||
assert out["oldestIdx"] == 4
|
||||
|
||||
|
||||
def test_untruncated_snapshot_clears_the_bounded_preflight_state():
|
||||
"""An untruncated response must clear the bounded preflight's state.
|
||||
|
||||
This is the `/compress` success-path shape from the CORE finding: the
|
||||
preflight bounded the tail, the response carries the full transcript, and
|
||||
the client must end at ``truncated=false`` / ``offset=0`` so absolute
|
||||
transcript indices line up again.
|
||||
"""
|
||||
out = _drive(truncated=False, offset=0)
|
||||
assert out["truncated"] is False, (
|
||||
"an untruncated response must clear _messagesTruncated; the bounded "
|
||||
f"preflight state leaked (got {out['truncated']})"
|
||||
)
|
||||
assert out["oldestIdx"] == 0
|
||||
|
||||
|
||||
def test_compress_success_path_restores_paging_before_render():
|
||||
"""Source lock: the compress success path restores paging before render.
|
||||
|
||||
The round-3 CORE reproduction was a successful `/compress` whose response
|
||||
carried 100 full rows while the client stayed at
|
||||
``_messagesTruncated=true, _oldestIdx=70``. The restore must appear
|
||||
between the response assignment and ``renderMessages()`` — the same
|
||||
ordering the settle and cancel paths already use.
|
||||
"""
|
||||
block = _compress_success_block()
|
||||
|
||||
assign_idx = block.find("S.messages=")
|
||||
restore_idx = block.find("_messagesTruncated=")
|
||||
offset_idx = block.find("_oldestIdx=")
|
||||
render_idx = block.find("renderMessages()")
|
||||
|
||||
assert assign_idx >= 0, "compress success path must assign S.messages"
|
||||
assert restore_idx > assign_idx, (
|
||||
"_messagesTruncated must be restored from the response AFTER the "
|
||||
"assignment — the preflight's bounded value otherwise survives"
|
||||
)
|
||||
assert offset_idx > assign_idx, "_oldestIdx must be restored from _messages_offset"
|
||||
assert render_idx > restore_idx and render_idx > offset_idx, (
|
||||
"paging must be restored BEFORE renderMessages() so the rendered rows "
|
||||
"and any absolute index computation see the restored values"
|
||||
)
|
||||
# The restore must read the response's own signals, not hardcode false.
|
||||
assert "_messages_truncated" in block, (
|
||||
"the restore must read data.session._messages_truncated rather than "
|
||||
"hardcoding _messagesTruncated=false"
|
||||
)
|
||||
assert "_messages_offset" in block, (
|
||||
"the restore must read data.session._messages_offset"
|
||||
)
|
||||
|
||||
|
||||
def test_truncation_signal_selects_suffix_strategy_exclusively():
|
||||
"""Source lock: the strategy is a truncation-gated ternary, never an `||`.
|
||||
|
||||
The pre-fix expression was ``_stagedMatchesCurrentPrefix ||
|
||||
_stagedMatchesCurrentSuffix``: when repeated identical turns made both
|
||||
comparisons succeed the prefix branch won and spliced at the wrong
|
||||
offset. The gate is now ``_truncatedRecovery ? suffix : prefix``.
|
||||
"""
|
||||
block = _splice_block()
|
||||
|
||||
assert "_truncatedRecovery" in block, (
|
||||
"the splice strategy must consult the truncation signal"
|
||||
)
|
||||
assert "_truncatedRecovery?_stagedMatchesCurrentSuffix" in block.replace(" ", ""), (
|
||||
"a truncated recovery must select the SUFFIX comparison exclusively"
|
||||
)
|
||||
# The old defect: a bare `||` between the two comparisons.
|
||||
assert "||_stagedMatchesCurrentSuffix" not in block.replace(" ", ""), (
|
||||
"prefix and suffix comparisons must not be OR-ed; preferring the "
|
||||
"prefix when both match splices repeated turns at the wrong offset"
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""#7754: a marked native-image state row that replays its sidecar row must consume that replay slot.
|
||||
|
||||
Before the fix, the row-id fast path for a marked native-image mirror row `continue`d without
|
||||
advancing the replay index. The edited checkpoint then never read as consumed, and a rerun reply
|
||||
that exists only in state.db was truncated. Master (no marker branch) keeps that reply.
|
||||
"""
|
||||
|
||||
import api.models as models
|
||||
|
||||
|
||||
def _merge(*, marked):
|
||||
field = models._WEBUI_UNMATCHED_NATIVE_IMAGE_MIRROR_FIELD
|
||||
image_row = {
|
||||
"role": "user",
|
||||
"content": "look",
|
||||
"timestamp": 860.0,
|
||||
"_state_db_row_id": 42,
|
||||
"api_content": "PAYLOAD-A",
|
||||
}
|
||||
sidecar = [
|
||||
{"role": "user", "content": "u1", "timestamp": 100.0},
|
||||
{"role": "assistant", "content": "a1", "timestamp": 101.0},
|
||||
dict(image_row),
|
||||
{"role": "assistant", "content": "a_img", "timestamp": 861.0},
|
||||
{"role": "user", "content": "uE", "timestamp": 900.0},
|
||||
]
|
||||
state_image_row = dict(image_row)
|
||||
if marked:
|
||||
state_image_row[field] = True
|
||||
state = [
|
||||
{"role": "user", "content": "u1", "timestamp": 100.0},
|
||||
{"role": "assistant", "content": "a1", "timestamp": 101.0},
|
||||
state_image_row,
|
||||
{"role": "assistant", "content": "a_img", "timestamp": 861.0},
|
||||
{"role": "user", "content": "u_old", "timestamp": 870.0},
|
||||
{"role": "assistant", "content": "a_old", "timestamp": 871.0},
|
||||
{"role": "user", "content": "uE", "timestamp": 900.0},
|
||||
{"role": "assistant", "content": "aE", "timestamp": 901.0},
|
||||
]
|
||||
return models.merge_session_messages_append_only(
|
||||
sidecar,
|
||||
state,
|
||||
truncation_watermark=900.0,
|
||||
truncation_boundary=861.0,
|
||||
incoming_provenance="state_db",
|
||||
)
|
||||
|
||||
|
||||
def _contents(rows):
|
||||
return [row.get("content") for row in rows]
|
||||
|
||||
|
||||
def test_marked_image_row_keeps_state_only_reply_after_edited_checkpoint():
|
||||
merged = _merge(marked=True)
|
||||
assert _contents(merged) == ["u1", "a1", "look", "a_img", "uE", "aE"]
|
||||
|
||||
|
||||
def test_marked_and_unmarked_image_rows_merge_identically():
|
||||
assert _contents(_merge(marked=True)) == _contents(_merge(marked=False))
|
||||
|
||||
|
||||
def test_removed_rows_between_boundary_and_watermark_stay_out():
|
||||
contents = _contents(_merge(marked=True))
|
||||
assert "u_old" not in contents
|
||||
assert "a_old" not in contents
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Access records must reach the process output, even during agent capture."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("capture", ["redirected", "closed"])
|
||||
def test_successful_chat_start_delivers_access_record(tmp_path, capture):
|
||||
# A subprocess tests the delivered fd text, not an internal counter or a
|
||||
# patched print. Keep the real chat route and HTTP response/logging stack;
|
||||
# replace only model discovery and execution with deterministic fixtures.
|
||||
script = textwrap.dedent('''
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
from server import Handler
|
||||
from api import routes
|
||||
from api.models import Session
|
||||
|
||||
handler = Handler.__new__(Handler)
|
||||
handler.command = "POST"
|
||||
handler.path = "/api/chat/start"
|
||||
handler.requestline = "POST /api/chat/start HTTP/1.1"
|
||||
handler.request_version = "HTTP/1.1"
|
||||
handler.client_address = ("127.0.0.1", 12345)
|
||||
handler.headers = {}
|
||||
handler.wfile = io.BytesIO()
|
||||
handler._req_t0 = time.time()
|
||||
session = Session(session_id="log-delivery", model="fixture")
|
||||
original = sys.stdout
|
||||
captured = io.StringIO()
|
||||
if sys.argv[1] == "closed":
|
||||
captured.close()
|
||||
|
||||
def start_run(session, **kwargs):
|
||||
# Agent tool capture can redirect stdout from another thread before
|
||||
# chat/start writes its acceptance response.
|
||||
def capture_output():
|
||||
sys.stdout = captured
|
||||
worker = threading.Thread(target=capture_output)
|
||||
worker.start()
|
||||
worker.join()
|
||||
return {"stream_id": "log-stream", "session_id": session.session_id}
|
||||
|
||||
try:
|
||||
with (
|
||||
patch.object(routes, "_agent_runtime_barrier_response", return_value=None),
|
||||
patch.object(routes, "_get_or_materialize_session", return_value=session),
|
||||
patch.object(routes, "_resolve_chat_workspace_with_recovery", return_value="/tmp"),
|
||||
patch.object(routes, "_read_profile_model_config", return_value=(None, "fixture", {})),
|
||||
patch.object(routes, "get_config", return_value={}),
|
||||
patch.object(routes, "_resolve_compatible_session_model_state", return_value=("fixture", None, False)),
|
||||
patch.object(routes, "_start_run", side_effect=start_run),
|
||||
):
|
||||
routes._handle_chat_start(handler, {"session_id": session.session_id, "message": "wake"})
|
||||
finally:
|
||||
sys.stdout = original
|
||||
response = handler.wfile.getvalue()
|
||||
assert b"HTTP/1.1 200" in response, response
|
||||
assert json.loads(response.split(b"\\r\\n\\r\\n", 1)[1])["stream_id"] == "log-stream"
|
||||
''')
|
||||
env = {
|
||||
"PATH": os.environ["PATH"],
|
||||
"HOME": str(tmp_path),
|
||||
"HERMES_HOME": str(tmp_path / "home"),
|
||||
"HERMES_WEBUI_STATE_DIR": str(tmp_path / "state"),
|
||||
"HERMES_WEBUI_TEST_NETWORK_BLOCK": "1",
|
||||
}
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script, capture],
|
||||
cwd=Path(__file__).resolve().parents[1], env=env,
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
records = [
|
||||
json.loads(line.removeprefix("[webui] "))
|
||||
for line in result.stdout.splitlines() if line.startswith("[webui] ")
|
||||
]
|
||||
assert len(records) == 1, f"Successful chat/start lost its access record: {result.stdout!r}"
|
||||
assert records[0]["method"] == "POST"
|
||||
assert records[0]["path"] == "/api/chat/start"
|
||||
assert records[0]["status"] == 200
|
||||
assert records[0]["ms"] >= 0
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Hermes Agent names that moved modules keep working at every WebUI call site.
|
||||
|
||||
The Agent's September 2026 decomposition moved names such as
|
||||
``tools.approval.set_current_session_key`` into sibling modules. The old paths
|
||||
resolved only through warning ``__getattr__`` pointers that are removed on
|
||||
schedule; with the pointers gone, the WebUI's broad ``except Exception`` call
|
||||
sites silently dropped approval session binding, MCP discovery/status, Claude
|
||||
Code credential linking, and every kanban connection.
|
||||
|
||||
The call-site tests run each affected WebUI entry point against two fake Agent
|
||||
shapes: ``pre_split`` (the name still lives on its original module) and
|
||||
``pointer_removed`` (the name lives only in its new home module, and the
|
||||
original module no longer resolves it).
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
import warnings
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from api.agent_compat import agent_attr
|
||||
|
||||
SHAPES = ("pre_split", "pointer_removed")
|
||||
|
||||
|
||||
class _CompatWarning(FutureWarning):
|
||||
pass
|
||||
|
||||
|
||||
def _real(value):
|
||||
return value
|
||||
|
||||
|
||||
def _install(monkeypatch, name, **attrs):
|
||||
mod = types.ModuleType(name)
|
||||
for key, value in attrs.items():
|
||||
setattr(mod, key, value)
|
||||
monkeypatch.setitem(sys.modules, name, mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _install_agent(monkeypatch, shape, facade, moved, native=None):
|
||||
"""Install a fake Agent module ``facade``.
|
||||
|
||||
``moved`` maps each new home module to the ``{name: object}`` it now owns.
|
||||
``pre_split`` puts those names on ``facade``; ``pointer_removed`` installs
|
||||
them only in their home modules. ``native`` names always stay on ``facade``.
|
||||
"""
|
||||
names = dict(native or {})
|
||||
for home, attrs in moved.items():
|
||||
if shape == "pre_split":
|
||||
names.update(attrs)
|
||||
else:
|
||||
_install(monkeypatch, home, **attrs)
|
||||
return _install(monkeypatch, facade, **names)
|
||||
|
||||
|
||||
# ── resolver ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _split_facade(monkeypatch, *, with_pointer):
|
||||
"""An Agent after the split: the name lives in the home module; the facade
|
||||
optionally keeps a warning PEP 562 pointer (the temporary compat layer)."""
|
||||
_install(monkeypatch, "fakeagent_home", moved=_real)
|
||||
facade = _install(monkeypatch, "fakeagent_facade", native=_real)
|
||||
if with_pointer:
|
||||
def __getattr__(name):
|
||||
if name == "moved":
|
||||
warnings.warn("`fakeagent_facade.moved` moved", _CompatWarning, stacklevel=2)
|
||||
return _real
|
||||
raise AttributeError(name)
|
||||
facade.__getattr__ = __getattr__
|
||||
return facade
|
||||
|
||||
|
||||
def test_pre_split_agent_uses_original_module(monkeypatch):
|
||||
orig = _install(monkeypatch, "fakeagent_facade", moved=lambda: "pre-split")
|
||||
assert agent_attr("fakeagent_facade", "moved", "fakeagent_missing_home")() == "pre-split"
|
||||
assert agent_attr(orig, "moved", "fakeagent_missing_home")() == "pre-split"
|
||||
|
||||
|
||||
def test_split_agent_with_pointer_uses_home_without_compat_warning(monkeypatch):
|
||||
_split_facade(monkeypatch, with_pointer=True)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", _CompatWarning)
|
||||
assert agent_attr("fakeagent_facade", "moved", "fakeagent_home") is _real
|
||||
|
||||
|
||||
def test_split_agent_after_pointer_removal_uses_home(monkeypatch):
|
||||
facade = _split_facade(monkeypatch, with_pointer=False)
|
||||
with pytest.raises(AttributeError):
|
||||
_ = facade.moved # what `from facade import moved` hits after the removal
|
||||
assert agent_attr(facade, "moved", "fakeagent_home") is _real
|
||||
|
||||
|
||||
def test_stub_on_original_module_still_wins(monkeypatch):
|
||||
_split_facade(monkeypatch, with_pointer=False)
|
||||
stub = lambda: "stub" # noqa: E731
|
||||
monkeypatch.setattr(sys.modules["fakeagent_facade"], "moved", stub, raising=False)
|
||||
assert agent_attr("fakeagent_facade", "moved", "fakeagent_home") is stub
|
||||
|
||||
|
||||
def test_non_module_double_uses_plain_attribute(monkeypatch):
|
||||
class FakeKanbanDB:
|
||||
def connect(self, *, board=None):
|
||||
return board
|
||||
|
||||
double = FakeKanbanDB()
|
||||
assert agent_attr(double, "connect", "fakeagent_home")(board="b") == "b"
|
||||
assert agent_attr(double, "connect_closing", "fakeagent_home", None) is None
|
||||
|
||||
|
||||
def test_default_when_name_is_absent_everywhere(monkeypatch):
|
||||
_split_facade(monkeypatch, with_pointer=False)
|
||||
assert agent_attr("fakeagent_facade", "nope", "fakeagent_home", None) is None
|
||||
with pytest.raises(AttributeError):
|
||||
agent_attr("fakeagent_facade", "nope", "fakeagent_home")
|
||||
|
||||
|
||||
def test_missing_agent_raises_import_error_or_returns_default():
|
||||
with pytest.raises(ImportError):
|
||||
agent_attr("fakeagent_not_installed", "moved", "fakeagent_not_installed_home")
|
||||
assert agent_attr("fakeagent_not_installed", "moved", "fakeagent_not_installed_home", None) is None
|
||||
|
||||
|
||||
# ── WebUI call sites ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", SHAPES)
|
||||
def test_turn_session_identity_binds_and_resets_agent_approval_key(monkeypatch, shape):
|
||||
import api.streaming as streaming
|
||||
|
||||
calls = []
|
||||
|
||||
def set_current_session_key(sid):
|
||||
calls.append(("set", sid))
|
||||
return f"token-{sid}"
|
||||
|
||||
def reset_current_session_key(token):
|
||||
calls.append(("reset", token))
|
||||
|
||||
_install_agent(monkeypatch, shape, "tools.approval", {"tools.approval_context": {
|
||||
"set_current_session_key": set_current_session_key,
|
||||
"reset_current_session_key": reset_current_session_key,
|
||||
}})
|
||||
|
||||
tokens = streaming._set_turn_session_identity("sess-1")
|
||||
assert tokens.get("approval") == "token-sess-1"
|
||||
streaming._reset_turn_session_identity(tokens)
|
||||
assert calls == [("set", "sess-1"), ("reset", "token-sess-1")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", SHAPES)
|
||||
def test_mcp_runtime_status_reads_agent_registry(monkeypatch, shape):
|
||||
import api.routes as routes
|
||||
|
||||
statuses = [{"name": "alpha", "connected": True}, {"name": "beta", "connected": False}]
|
||||
_install_agent(monkeypatch, shape, "tools.mcp_tool", {"tools.mcp_tool_discovery": {
|
||||
"get_mcp_status": lambda: statuses,
|
||||
}})
|
||||
|
||||
assert routes._mcp_runtime_status_by_name() == {"alpha": statuses[0], "beta": statuses[1]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", SHAPES)
|
||||
def test_reload_mcp_command_shuts_down_and_rediscovers(monkeypatch, shape):
|
||||
import api.commands as commands
|
||||
|
||||
servers = {"old": object()}
|
||||
calls = []
|
||||
|
||||
def shutdown_mcp_servers():
|
||||
calls.append("shutdown")
|
||||
servers.clear()
|
||||
|
||||
def discover_mcp_tools():
|
||||
calls.append("discover")
|
||||
servers.update(old=object(), new=object())
|
||||
return ["t1", "t2", "t3"]
|
||||
|
||||
_install_agent(
|
||||
monkeypatch, shape, "tools.mcp_tool",
|
||||
{
|
||||
"tools.mcp_tool_lifecycle": {"shutdown_mcp_servers": shutdown_mcp_servers},
|
||||
"tools.mcp_tool_discovery": {"discover_mcp_tools": discover_mcp_tools},
|
||||
},
|
||||
native={"_servers": servers, "_lock": threading.Lock()},
|
||||
)
|
||||
|
||||
out = commands._run_reload_mcp_command()
|
||||
assert calls == ["shutdown", "discover"]
|
||||
assert "Reconnected: old" in out
|
||||
assert "Added: new" in out
|
||||
assert "3 tool(s) available across 2 server(s)" in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", SHAPES)
|
||||
def test_claude_code_credentials_read_through_agent(monkeypatch, shape):
|
||||
import api.oauth as oauth
|
||||
|
||||
creds = {"accessToken": "cc-access", "refreshToken": "cc-refresh"}
|
||||
_install_agent(monkeypatch, shape, "agent.anthropic_adapter", {"agent.anthropic_credentials": {
|
||||
"read_claude_code_credentials": lambda: creds,
|
||||
"is_claude_code_token_valid": lambda value: value is creds,
|
||||
}})
|
||||
|
||||
assert oauth._read_claude_code_credentials() is creds
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", SHAPES)
|
||||
def test_lmstudio_reasoning_options_use_agent_probe(monkeypatch, shape):
|
||||
import api.config as config
|
||||
|
||||
seen = []
|
||||
|
||||
def lmstudio_model_reasoning_options(model, base_url, api_key=None, timeout=5.0):
|
||||
seen.append((model, base_url, timeout))
|
||||
return ["low", "high"]
|
||||
|
||||
monkeypatch.setattr(config, "_lmstudio_reasoning_probe_options_fallback", lambda *a, **k: ["fallback"])
|
||||
_install_agent(monkeypatch, shape, "hermes_cli.models", {"hermes_cli.models_local": {
|
||||
"lmstudio_model_reasoning_options": lmstudio_model_reasoning_options,
|
||||
}})
|
||||
|
||||
assert config._lmstudio_model_reasoning_options("qwen", "http://127.0.0.1:1234", timeout=2.0) == ["low", "high"]
|
||||
assert seen == [("qwen", "http://127.0.0.1:1234", 2.0)]
|
||||
|
||||
|
||||
_KANBAN_SQL = """
|
||||
CREATE TABLE tasks (id INTEGER PRIMARY KEY, status TEXT);
|
||||
INSERT INTO tasks (status) VALUES ('todo'), ('todo'), ('done'), ('archived');
|
||||
CREATE TABLE task_events (
|
||||
id INTEGER PRIMARY KEY, task_id TEXT, run_id TEXT, kind TEXT, payload TEXT, created_at INTEGER
|
||||
);
|
||||
INSERT INTO task_events VALUES (1, 't1', NULL, 'created', '{"a": 1}', 100), (2, 't2', 'r1', 'moved', NULL, 101);
|
||||
"""
|
||||
|
||||
|
||||
def _install_kanban_agent(monkeypatch, shape):
|
||||
import api.kanban_bridge as bridge
|
||||
|
||||
def connect(*, board=None):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.executescript(_KANBAN_SQL)
|
||||
return conn
|
||||
|
||||
def connect_closing(*, board=None):
|
||||
return contextlib.closing(connect(board=board))
|
||||
|
||||
def dispatch_once(conn, dry_run=False, max_spawn=8):
|
||||
tasks = conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0]
|
||||
return {"dry_run": dry_run, "max_spawn": max_spawn, "tasks_seen": tasks}
|
||||
|
||||
kb = _install_agent(
|
||||
monkeypatch, shape, "hermes_cli.kanban_db",
|
||||
{
|
||||
"hermes_cli.kanban_db_connect": {"connect": connect, "connect_closing": connect_closing},
|
||||
"hermes_cli.kanban_db_dispatch": {"dispatch_once": dispatch_once},
|
||||
},
|
||||
native={"init_db": lambda *, board=None: None, "board_exists": lambda slug: True, "DEFAULT_BOARD": "default"},
|
||||
)
|
||||
pkg = types.ModuleType("hermes_cli")
|
||||
pkg.kanban_db = kb
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", pkg)
|
||||
return bridge
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", SHAPES)
|
||||
def test_kanban_connection_is_closed_after_use(monkeypatch, shape):
|
||||
bridge = _install_kanban_agent(monkeypatch, shape)
|
||||
|
||||
with bridge._conn(board=None) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 4
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
conn.execute("SELECT 1") # connect_closing, not a leaked raw connection
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", SHAPES)
|
||||
def test_kanban_counts_and_event_feed_read_agent_db(monkeypatch, shape):
|
||||
bridge = _install_kanban_agent(monkeypatch, shape)
|
||||
|
||||
assert bridge._board_counts_for_slug("default") == {"todo": 2, "done": 1}
|
||||
cursor, events = bridge._kanban_sse_fetch_new(None, 0)
|
||||
assert cursor == 2
|
||||
assert [(e["id"], e["kind"], e["payload"]) for e in events] == [(1, "created", {"a": 1}), (2, "moved", None)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", SHAPES)
|
||||
def test_kanban_dispatch_runs_agent_dispatcher(monkeypatch, shape):
|
||||
bridge = _install_kanban_agent(monkeypatch, shape)
|
||||
|
||||
parsed = SimpleNamespace(path="/api/kanban/dispatch", query="dry_run=true&max=3")
|
||||
assert bridge._dispatch_payload(parsed) == {"dry_run": True, "max_spawn": 3, "tasks_seen": 4}
|
||||
@@ -13,6 +13,152 @@ REPO = Path(__file__).resolve().parent.parent
|
||||
STREAMING_PY = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _compute_agent_cache_signature_source() -> str:
|
||||
"""Return the source of the `_compute_agent_cache_signature()` helper.
|
||||
|
||||
The cache-signature blob used to be inlined in the streaming send path; it
|
||||
now lives in this helper so the initial send and both self-heal retry paths
|
||||
derive the signature from the same final runtime bundle.
|
||||
"""
|
||||
start = STREAMING_PY.index("def _compute_agent_cache_signature(")
|
||||
end = STREAMING_PY.index("\ndef ", start)
|
||||
return STREAMING_PY[start:end]
|
||||
|
||||
|
||||
def _signature_blob() -> str:
|
||||
"""Return the `_json.dumps([...])` field list the signature hashes."""
|
||||
helper = _compute_agent_cache_signature_source()
|
||||
blob_start = helper.index("_sig_blob = _json.dumps")
|
||||
blob_end = helper.index("], sort_keys=True)", blob_start)
|
||||
return helper[blob_start:blob_end]
|
||||
|
||||
|
||||
def _production_signature_calls() -> list[tuple[int, str]]:
|
||||
"""Return `(offset, source)` for every production signature call site.
|
||||
|
||||
Paren-balanced so the whole multi-line keyword-argument list is captured,
|
||||
and every live call site is returned so a retry path cannot silently drop a
|
||||
field that the initial send still passes. Commented-out occurrences are
|
||||
skipped -- a disabled retry call must read as missing, not as present.
|
||||
"""
|
||||
marker = "_agent_sig = _compute_agent_cache_signature("
|
||||
calls: list[tuple[int, str]] = []
|
||||
pos = STREAMING_PY.find(marker)
|
||||
while pos != -1:
|
||||
line_start = STREAMING_PY.rfind("\n", 0, pos) + 1
|
||||
if STREAMING_PY[line_start:pos].strip():
|
||||
# Commented-out (or otherwise non-statement) occurrence: it no
|
||||
# longer runs, so it must not count as a live call site.
|
||||
pos = STREAMING_PY.find(marker, pos + 1)
|
||||
continue
|
||||
depth = 0
|
||||
for idx in range(pos + len(marker) - 1, len(STREAMING_PY)):
|
||||
char = STREAMING_PY[idx]
|
||||
if char == "(":
|
||||
depth += 1
|
||||
elif char == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
calls.append((pos, STREAMING_PY[pos:idx + 1]))
|
||||
break
|
||||
else:
|
||||
raise AssertionError("unterminated _compute_agent_cache_signature( call")
|
||||
pos = STREAMING_PY.find(marker, pos + 1)
|
||||
return calls
|
||||
|
||||
|
||||
# Lifecycle anchors for the streaming send path. Pinning one signature call to
|
||||
# each region keeps the oracle honest: a retry call that silently disappears
|
||||
# fails the region check instead of hiding behind the calls that remain.
|
||||
INITIAL_SEND_MARKER = "# ── Agent cache: reuse across messages in the same session ──"
|
||||
RETURNED_ERROR_SELF_HEAL_MARKER = (
|
||||
"logger.info('[webui] self-heal: retrying stream after credential refresh')"
|
||||
)
|
||||
RAISED_EXCEPTION_SELF_HEAL_MARKER = (
|
||||
"logger.info('[webui] self-heal (except path): "
|
||||
"retrying stream after credential refresh')"
|
||||
)
|
||||
|
||||
# Each region ends at the atomic registration/publication call that consumes
|
||||
# the signature it just computed. The shared helper now writes the cache under
|
||||
# the Stop lock; the call boundary must still follow fresh signature computation.
|
||||
# A signature call moved below its publication lands outside the region and
|
||||
# fails, rather than silently reusing a stale `_agent_sig` on a retry.
|
||||
INITIAL_SEND_CACHE_WRITE_MARKER = (
|
||||
"if not _register_agent_if_current(agent, _agent_sig if _cache_new_agent else None):"
|
||||
)
|
||||
RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER = (
|
||||
"if not _register_agent_if_current(agent, _agent_sig):"
|
||||
)
|
||||
RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER = (
|
||||
"if not _register_agent_if_current(_heal_agent, _agent_sig):"
|
||||
)
|
||||
|
||||
|
||||
def _marker_offset(marker: str) -> int:
|
||||
"""Return the single offset of a lifecycle marker in streaming.py."""
|
||||
offset = STREAMING_PY.index(marker)
|
||||
assert STREAMING_PY.find(marker, offset + 1) == -1, (
|
||||
"lifecycle marker is no longer unique in streaming.py:\n" + marker
|
||||
)
|
||||
return offset
|
||||
|
||||
|
||||
def _signature_calls_by_region(
|
||||
calls: list[tuple[int, str]],
|
||||
) -> dict[str, tuple[int, str]]:
|
||||
"""Map each lifecycle region to the single signature call inside it.
|
||||
|
||||
The regions are the initial send, the returned-error self-heal retry and
|
||||
the raised-exception self-heal retry; each runs from its lifecycle anchor
|
||||
to the cache write that consumes `_agent_sig`. Requiring exactly one call
|
||||
inside those bounds means neither a dropped retry call (masked by its
|
||||
surviving siblings) nor a call recomputed after its own cache write (which
|
||||
would leave the write storing a stale signature) can read as correct.
|
||||
"""
|
||||
initial = _marker_offset(INITIAL_SEND_MARKER)
|
||||
initial_cache_write = _marker_offset(INITIAL_SEND_CACHE_WRITE_MARKER)
|
||||
returned_error = _marker_offset(RETURNED_ERROR_SELF_HEAL_MARKER)
|
||||
returned_error_cache_write = _marker_offset(
|
||||
RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER
|
||||
)
|
||||
raised_exception = _marker_offset(RAISED_EXCEPTION_SELF_HEAL_MARKER)
|
||||
raised_exception_cache_write = _marker_offset(
|
||||
RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER
|
||||
)
|
||||
assert (
|
||||
initial
|
||||
< initial_cache_write
|
||||
< returned_error
|
||||
< returned_error_cache_write
|
||||
< raised_exception
|
||||
< raised_exception_cache_write
|
||||
), (
|
||||
"streaming.py lifecycle regions are out of order; these anchors no "
|
||||
"longer describe the send path"
|
||||
)
|
||||
|
||||
bounds = {
|
||||
"initial send": (initial, initial_cache_write),
|
||||
"returned-error self-heal": (returned_error, returned_error_cache_write),
|
||||
"raised-exception self-heal": (
|
||||
raised_exception,
|
||||
raised_exception_cache_write,
|
||||
),
|
||||
}
|
||||
by_region: dict[str, tuple[int, str]] = {}
|
||||
for region, (start, end) in bounds.items():
|
||||
found = [(offset, call) for offset, call in calls if start < offset < end]
|
||||
assert len(found) == 1, (
|
||||
"expected exactly one _compute_agent_cache_signature() call in the "
|
||||
f"{region} region of streaming.py -- between its lifecycle anchor "
|
||||
f"and the cache write that stores `_agent_sig` -- found "
|
||||
f"{len(found)}"
|
||||
)
|
||||
by_region[region] = found[0]
|
||||
return by_region
|
||||
|
||||
|
||||
def test_streaming_agent_reads_agent_max_turns_from_config():
|
||||
assert "_agent_cfg_for_iterations" in STREAMING_PY
|
||||
assert "_agent_cfg_for_iterations.get('max_turns')" in STREAMING_PY
|
||||
@@ -25,6 +171,19 @@ def test_streaming_agent_passes_max_iterations_to_aiagent():
|
||||
|
||||
|
||||
def test_streaming_agent_cache_signature_includes_max_iterations():
|
||||
sig_start = STREAMING_PY.index("_sig_blob = _json.dumps")
|
||||
sig_block = STREAMING_PY[sig_start:STREAMING_PY.index("], sort_keys=True)", sig_start)]
|
||||
assert "_max_iterations_cfg or ''" in sig_block
|
||||
assert "max_iterations_cfg or ''" in _signature_blob(), (
|
||||
"_compute_agent_cache_signature() must hash max_iterations_cfg, or a "
|
||||
"max_turns change reuses the cached agent built on the old budget."
|
||||
)
|
||||
|
||||
calls = _production_signature_calls()
|
||||
assert len(calls) == 3, (
|
||||
"expected exactly three _compute_agent_cache_signature() call sites "
|
||||
"(initial send plus both self-heal retries), found "
|
||||
f"{len(calls)}"
|
||||
)
|
||||
for region, (_offset, call) in _signature_calls_by_region(calls).items():
|
||||
assert "max_iterations_cfg=_max_iterations_cfg" in call, (
|
||||
f"the {region} signature call must pass the resolved "
|
||||
"max_iterations budget:\n" + call
|
||||
)
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Stop must not lose to cache publication after an Agent was registered.
|
||||
|
||||
Run the real worker and Stop primitive. Pause immediately after the registration
|
||||
lock is released, not inside the constructor covered by the older tests. Only
|
||||
external Agent/provider calls are synthetic; no live credentials are used.
|
||||
"""
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
from api import config, session_lifecycle, streaming
|
||||
from tests.test_steer_worker_boundaries import worker_scene as worker_scene
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["initial", "cached", "returned", "exception"])
|
||||
@pytest.mark.parametrize("cancellation", ["stop", "event-only", "detached-only", "successor", "same-agent-successor", "same-agent-completed", "none"])
|
||||
def test_stop_after_registration_before_cache_or_invocation(worker_scene, monkeypatch, path, cancellation):
|
||||
scene = worker_scene
|
||||
reached, release = threading.Event(), threading.Event()
|
||||
created, publications, finalized = [], [], []
|
||||
worker_id = [None]
|
||||
paused = [False]
|
||||
successor = object()
|
||||
stop_interrupt_seen = threading.Event()
|
||||
successor_interrupt_count = None
|
||||
monkeypatch.setattr(session_lifecycle, "_sessions", {})
|
||||
monkeypatch.setattr(config, "SESSION_WRITEBACK_OWNERS", {})
|
||||
config.register_session_writeback_owner("original", "run")
|
||||
|
||||
def on_init():
|
||||
created.append(scene.agent)
|
||||
interrupt = scene.agent.interrupt
|
||||
def observed_interrupt(reason):
|
||||
result = interrupt(reason)
|
||||
stop_interrupt_seen.set()
|
||||
return result
|
||||
scene.agent.interrupt = observed_interrupt
|
||||
|
||||
def on_run():
|
||||
assert scene.lock.owner != threading.get_ident(), "provider invocation under stream lock"
|
||||
if path in ("returned", "exception") and len(created) == 1:
|
||||
if path == "exception":
|
||||
raise RuntimeError("401 unauthorized")
|
||||
scene.result = {"error": "401 unauthorized", "messages": []}
|
||||
else:
|
||||
scene.result = None
|
||||
|
||||
scene.on_init, scene.on_run = on_init, on_run
|
||||
if path == "cached":
|
||||
scene.run()
|
||||
assert scene.calls.count("run") == 1
|
||||
assert config.SESSION_AGENT_CACHE["original"][0] is created[0]
|
||||
scene.calls.clear()
|
||||
config.STREAMS["run"] = scene.events
|
||||
config.STREAM_SESSION_OWNERS["run"] = "original"
|
||||
scene.session.active_stream_id = "run"
|
||||
scene.session.pending_user_message = "Do the task."
|
||||
scene.session.pending_started_at = 2.0
|
||||
scene.session.save()
|
||||
config.register_session_writeback_owner("original", "run")
|
||||
stop_interrupt_seen.clear()
|
||||
|
||||
class ObservedCache(OrderedDict):
|
||||
def __setitem__(self, key, value):
|
||||
if key == "original":
|
||||
publications.append((value[0], scene.lock.owner == threading.get_ident(),
|
||||
"run" in config.STREAMS))
|
||||
super().__setitem__(key, value)
|
||||
|
||||
cache = ObservedCache()
|
||||
# Preserve an existing cache hit without counting fixture setup as publication.
|
||||
for key, value in config.SESSION_AGENT_CACHE.items():
|
||||
OrderedDict.__setitem__(cache, key, value)
|
||||
monkeypatch.setattr(config, "SESSION_AGENT_CACHE", cache)
|
||||
if hasattr(streaming, "SESSION_AGENT_CACHE"):
|
||||
monkeypatch.setattr(streaming, "SESSION_AGENT_CACHE", cache)
|
||||
monkeypatch.setattr(streaming, "_attempt_credential_self_heal", lambda *a, **kw: {
|
||||
"provider": "openai", "api_key": "synthetic-publication-fixture",
|
||||
"base_url": "http://127.0.0.1:1/v1",
|
||||
})
|
||||
|
||||
target_count = 2 if path in ("returned", "exception") else 1
|
||||
|
||||
def after_registration_unlock():
|
||||
if (worker_id[0] != threading.get_ident() or paused[0]
|
||||
or len(created) != target_count
|
||||
or config.AGENT_INSTANCES.get("run") is not created[-1]):
|
||||
return
|
||||
paused[0] = True
|
||||
reached.set()
|
||||
assert release.wait(8), "post-registration barrier timed out"
|
||||
|
||||
scene.lock.after_release = after_registration_unlock
|
||||
finalize = streaming._finalize_cancelled_turn
|
||||
|
||||
def observed_finalize(*args, **kwargs):
|
||||
assert scene.lock.owner != threading.get_ident(), "finalize under stream lock"
|
||||
finalized.append(True)
|
||||
return finalize(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(streaming, "_finalize_cancelled_turn", observed_finalize)
|
||||
|
||||
def run():
|
||||
worker_id[0] = threading.get_ident()
|
||||
scene.run()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
stop_future = None
|
||||
future = pool.submit(run)
|
||||
try:
|
||||
assert reached.wait(8), "worker never released target registration lock"
|
||||
retained = config.CANCEL_FLAGS["run"]
|
||||
if cancellation in ("stop", "successor", "same-agent-successor", "same-agent-completed"):
|
||||
stop_future = pool.submit(streaming.cancel_stream, "run")
|
||||
assert retained.wait(5), "Stop did not publish cancellation"
|
||||
assert stop_interrupt_seen.wait(5), "Stop has not finished its own interrupt"
|
||||
with config.STREAMS_LOCK:
|
||||
assert "run" not in config.STREAMS
|
||||
if cancellation in ("successor", "same-agent-successor", "same-agent-completed"):
|
||||
if cancellation.startswith("same-agent"):
|
||||
successor = created[-1]
|
||||
config.register_session_writeback_owner("original", "successor")
|
||||
with config.SESSION_AGENT_CACHE_LOCK:
|
||||
OrderedDict.__setitem__(cache, "original", (successor, "new-signature"))
|
||||
session_lifecycle.register_agent("original", successor)
|
||||
scene.session.active_stream_id = "successor"
|
||||
scene.session.pending_user_message = "Successor request"
|
||||
if cancellation == "same-agent-completed":
|
||||
config.clear_session_writeback_owner_if_owned("original", "successor")
|
||||
scene.session.active_stream_id = None
|
||||
scene.session.pending_user_message = None
|
||||
else:
|
||||
config.AGENT_INSTANCES["successor"] = successor
|
||||
config.register_active_run("successor", session_id="original", backend="legacy")
|
||||
if cancellation == "same-agent-successor":
|
||||
with successor.pending_lock:
|
||||
successor.pending.append("Successor-only guidance")
|
||||
successor_interrupt_count = scene.calls.count("interrupt")
|
||||
elif cancellation == "event-only":
|
||||
with config.STREAMS_LOCK:
|
||||
retained.set()
|
||||
config.CANCEL_FLAGS.pop("run")
|
||||
elif cancellation == "detached-only":
|
||||
with config.STREAMS_LOCK:
|
||||
config.STREAMS.pop("run")
|
||||
assert not retained.is_set()
|
||||
finally:
|
||||
release.set()
|
||||
future.result(timeout=12)
|
||||
if stop_future is not None:
|
||||
assert stop_future.result(timeout=12) is True
|
||||
|
||||
prior_runs = 1 if path in ("returned", "exception") else 0
|
||||
if cancellation == "none":
|
||||
assert scene.calls.count("run") == prior_runs + 1
|
||||
assert config.SESSION_AGENT_CACHE["original"][0] is created[-1]
|
||||
else:
|
||||
assert scene.calls.count("run") == prior_runs, "cancelled candidate was invoked"
|
||||
cached = config.SESSION_AGENT_CACHE.get("original")
|
||||
if not cancellation.startswith("same-agent"):
|
||||
assert not cached or cached[0] is not created[-1], "cancelled candidate stayed reusable"
|
||||
assert finalized, "cancellation did not reach terminal settlement"
|
||||
if successor_interrupt_count is not None:
|
||||
assert scene.calls.count("interrupt") == successor_interrupt_count, "stale worker interrupted its successor"
|
||||
assert config.SESSION_AGENT_CACHE["original"][0] is successor
|
||||
assert session_lifecycle._sessions["original"]["agent"] is successor
|
||||
if cancellation == "same-agent-successor":
|
||||
assert successor.pending == ["Successor-only guidance"], "stale terminal drain stole successor guidance"
|
||||
if cancellation == "same-agent-completed":
|
||||
assert scene.session.active_stream_id is None
|
||||
else:
|
||||
assert config.AGENT_INSTANCES["successor"] is successor
|
||||
assert scene.session.active_stream_id == "successor"
|
||||
assert scene.session.pending_user_message == "Successor request"
|
||||
assert all(held and live for _, held, live in publications), (
|
||||
"Agent cache publication escaped the stream/Stop admission boundary", publications)
|
||||
assert "run" not in config.AGENT_INSTANCES
|
||||
assert "run" not in config.ACTIVE_RUNS
|
||||
|
||||
|
||||
def test_cache_publication_and_registration_exclude_competing_stop(worker_scene, monkeypatch):
|
||||
"""Block the actual cache write; Stop cannot observe a half-published Agent."""
|
||||
scene = worker_scene
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
interrupted_state = []
|
||||
interrupt_observed = threading.Event()
|
||||
monkeypatch.setattr(session_lifecycle, "_sessions", {})
|
||||
monkeypatch.setattr(config, "SESSION_WRITEBACK_OWNERS", {})
|
||||
config.register_session_writeback_owner("original", "run")
|
||||
|
||||
class PublicationBarrier(OrderedDict):
|
||||
def __setitem__(self, key, value):
|
||||
if key == "original":
|
||||
entered.set()
|
||||
assert release.wait(8), "cache publication was not released"
|
||||
super().__setitem__(key, value)
|
||||
|
||||
cache = PublicationBarrier()
|
||||
monkeypatch.setattr(config, "SESSION_AGENT_CACHE", cache)
|
||||
if hasattr(streaming, "SESSION_AGENT_CACHE"):
|
||||
monkeypatch.setattr(streaming, "SESSION_AGENT_CACHE", cache)
|
||||
|
||||
def on_init():
|
||||
candidate = scene.agent
|
||||
interrupt = candidate.interrupt
|
||||
|
||||
def observed_interrupt(reason):
|
||||
interrupted_state.append((
|
||||
cache.get("original", (None,))[0] is candidate,
|
||||
session_lifecycle._sessions.get("original", {}).get("agent") is candidate,
|
||||
))
|
||||
result = interrupt(reason)
|
||||
interrupt_observed.set()
|
||||
return result
|
||||
|
||||
candidate.interrupt = observed_interrupt
|
||||
|
||||
scene.on_init = on_init
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
worker = pool.submit(scene.run)
|
||||
stop = None
|
||||
try:
|
||||
assert entered.wait(8), "worker never reached cache publication"
|
||||
retained = config.CANCEL_FLAGS["run"]
|
||||
# Wait at the first publication unlock until Stop has won admission,
|
||||
# so the provider cannot outrun the controlled schedule.
|
||||
worker_ident = scene.lock.owner
|
||||
def wait_for_stop():
|
||||
if threading.get_ident() == worker_ident:
|
||||
scene.lock.after_release = None
|
||||
assert interrupt_observed.wait(5), "Stop did not interrupt the published Agent"
|
||||
scene.lock.after_release = wait_for_stop
|
||||
stop = pool.submit(streaming.cancel_stream, "run")
|
||||
assert scene.lock.contender.wait(5), "Stop bypassed publication's stream lock"
|
||||
assert not retained.is_set(), "Stop observed half-published Agent state"
|
||||
finally:
|
||||
release.set()
|
||||
worker.result(timeout=12)
|
||||
if stop is not None:
|
||||
assert stop.result(timeout=12) is True
|
||||
assert interrupted_state[0] == (True, True), "Stop did not see coherent cache/lifecycle publication"
|
||||
assert "run" not in scene.calls
|
||||
assert "original" not in cache
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Git identity loss must not fall back to forgeable source-file metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "-c", "user.name=Test", "-c", "user.email=test@example.invalid", *args],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def test_git_identity_loss_rejects_changed_source_with_preserved_metadata(
|
||||
monkeypatch, tmp_path: Path
|
||||
):
|
||||
"""A known Git identity cannot fall back to forgeable file metadata."""
|
||||
from api import agent_runtime
|
||||
|
||||
source_dir = tmp_path / "loaded-agent"
|
||||
source_dir.mkdir()
|
||||
module_file = source_dir / "run_agent.py"
|
||||
module_file.write_bytes(b"class AIAgent: pass\n")
|
||||
_git(source_dir, "init", "-q")
|
||||
_git(source_dir, "add", "run_agent.py")
|
||||
_git(source_dir, "commit", "-qm", "loaded agent")
|
||||
|
||||
loaded_module = types.ModuleType("run_agent")
|
||||
loaded_module.__file__ = str(module_file)
|
||||
monkeypatch.setitem(sys.modules, "run_agent", loaded_module)
|
||||
revision = _git(source_dir, "rev-parse", "HEAD")
|
||||
original_stat = module_file.stat()
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_SOURCE_DIR", source_dir.resolve())
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_MODULE_PATH", module_file.resolve())
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", revision)
|
||||
monkeypatch.setattr(
|
||||
agent_runtime, "_AGENT_MODULE_MTIME_NS", original_stat.st_mtime_ns,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
(source_dir / ".git").rename(tmp_path / "hidden-agent-git")
|
||||
module_file.write_bytes(b"class AIAgent: gasp\n")
|
||||
os.utime(module_file, ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns))
|
||||
fresh_stat = module_file.stat()
|
||||
assert fresh_stat.st_size == original_stat.st_size
|
||||
assert fresh_stat.st_mtime_ns == original_stat.st_mtime_ns
|
||||
|
||||
with pytest.raises(agent_runtime.AgentRuntimeChangedError):
|
||||
agent_runtime.ensure_agent_runtime_current()
|
||||
|
||||
|
||||
def test_raising_revision_reader_fails_closed(monkeypatch, tmp_path: Path):
|
||||
"""A revision read that RAISES must fail closed, not escape as a 500.
|
||||
|
||||
Every other identity-loss shape (deleted, permission-denied, empty,
|
||||
whitespace-only, corrupt, removed directory) already raises
|
||||
AgentRuntimeChangedError. A reader that throws is the same situation --
|
||||
the revision is unreadable, which is indistinguishable from changed --
|
||||
so it must produce the same typed error rather than propagating a raw
|
||||
OSError that the request layer would surface as an HTTP 500 instead of
|
||||
the intended stale-runtime response.
|
||||
"""
|
||||
from api import agent_runtime
|
||||
|
||||
source_dir = tmp_path / "raising-agent"
|
||||
source_dir.mkdir()
|
||||
module_file = source_dir / "run_agent.py"
|
||||
module_file.write_bytes(b"class AIAgent: pass\n")
|
||||
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_SOURCE_DIR", source_dir.resolve())
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_MODULE_PATH", module_file.resolve())
|
||||
monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", "0" * 40)
|
||||
|
||||
def _boom(*_args, **_kwargs):
|
||||
raise PermissionError("revision unreadable")
|
||||
|
||||
monkeypatch.setattr(agent_runtime, "_read_agent_revision", _boom)
|
||||
|
||||
with pytest.raises(agent_runtime.AgentRuntimeChangedError):
|
||||
agent_runtime.ensure_agent_runtime_current()
|
||||
@@ -854,7 +854,7 @@ def test_stream_admission_uses_one_gateway_ownership_snapshot(monkeypatch, gatew
|
||||
monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda _sid: None)
|
||||
monkeypatch.setattr(routes, "_is_hidden_empty_session", lambda _session: False)
|
||||
monkeypatch.setattr(routes, "_prepare_chat_start_session_for_stream", prepare)
|
||||
monkeypatch.setattr(routes, "set_last_workspace", lambda _workspace: None)
|
||||
monkeypatch.setattr(routes, "set_last_workspace", lambda _workspace, **_kw: None)
|
||||
monkeypatch.setattr(routes.threading, "Thread", FakeThread)
|
||||
monkeypatch.setattr(turn_journal, "append_turn_journal_event", lambda *_args, **_kwargs: {})
|
||||
|
||||
|
||||
@@ -416,6 +416,10 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds
|
||||
legacy_metadata_source = _function_source(
|
||||
_ui_js(), "_legacySettledFallbackHasToolMetadata"
|
||||
)
|
||||
# #2051: renderMessages() inserts a message block through this helper, so it is
|
||||
# evaluated with it. The shim's createElement() returns no template `content`, so the
|
||||
# helper takes its insertAdjacentHTML fallback here, exactly as before.
|
||||
insert_block_source = _function_source(_ui_js(), "_insertSegmentBlock")
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
class FakeClassList {{
|
||||
@@ -675,6 +679,7 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds
|
||||
function _scrollAfterMessageRender() {{}}
|
||||
function _maybeRecoverVirtualizedBlankViewport() {{ return false; }}
|
||||
function _updateMessageVirtualMeasurements() {{}}
|
||||
function _resetMessageVirtualMeasurementBurst() {{}}
|
||||
function postProcessRenderedMessages() {{}}
|
||||
function _postProcessWithAnchorSuppression() {{}}
|
||||
function _formatGatewayModelLabel() {{ return ''; }}
|
||||
@@ -682,6 +687,15 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds
|
||||
function _gatewayModelWarningText() {{ return ''; }}
|
||||
function _usedModelTurnChipLabel() {{ return ''; }}
|
||||
function _formatTurnDuration() {{ return ''; }}
|
||||
function _loadedCompactionMarkerRawIdxs() {{ return []; }}
|
||||
function _selectCompactionCardPlacements() {{
|
||||
return {{ preWindowMarkers: [], inlineMarkers: [], taskOwner: null }};
|
||||
}}
|
||||
function _insertCompactionCardNodes() {{
|
||||
return {{ insertedNodes: [], taskOwnerNode: null }};
|
||||
}}
|
||||
function _insertPreservedCompressionTaskFallback() {{ return false; }}
|
||||
function _pinCompactionCardAtTop() {{ return false; }}
|
||||
function _renderSettledAnchorSceneForMessage(message, segment, rawIdx) {{
|
||||
const group = new FakeElement('div');
|
||||
group.className = 'tool-worklog-group agent-activity-group';
|
||||
@@ -693,6 +707,7 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds
|
||||
|
||||
eval({json.dumps(transparent_source)});
|
||||
eval({json.dumps(legacy_metadata_source)});
|
||||
eval({json.dumps(insert_block_source)});
|
||||
eval({json.dumps(render_source)});
|
||||
|
||||
const toolResult = {{ role: 'tool', tool_call_id: 'toolu_1', content: 'tool result' }};
|
||||
|
||||
@@ -241,8 +241,18 @@ def test_anchor_scene_persistence_rejects_cross_profile_write(tmp_path, monkeypa
|
||||
SimpleNamespace(command="POST"),
|
||||
SimpleNamespace(path="/api/session/anchor-scene"),
|
||||
) is True
|
||||
assert captured.get("status") == 404
|
||||
assert "payload" not in captured # success j() never called
|
||||
# #7710: the generic request-guard now mirrors the detail-load
|
||||
# endpoint's contract — a session owned by a KNOWN other profile
|
||||
# yields 409 ``session_profile_mismatch`` so the client can offer
|
||||
# to switch to it (#5419). The 404 self-heal path is preserved for
|
||||
# the None-profile (unknown/legacy) case.
|
||||
assert captured.get("status") == 409, captured
|
||||
assert captured.get("payload") == {
|
||||
"error": "Session belongs to a different profile",
|
||||
"code": "session_profile_mismatch",
|
||||
"session_id": "foreignprofile1",
|
||||
"profile": "profile-b",
|
||||
}
|
||||
raw = json.loads((session_dir / "foreignprofile1.json").read_text(encoding="utf-8"))
|
||||
assert not raw.get("anchor_activity_scenes"), (
|
||||
"cross-profile request must NOT persist anchor_activity_scenes"
|
||||
|
||||
@@ -601,6 +601,85 @@ class TestApprovalHTTPEndpoints:
|
||||
r._gateway_queues.pop(sid, None)
|
||||
_STREAM_RUN_IDS.pop(stream_id, None)
|
||||
|
||||
def test_identity_v1_non_head_run_approval_settles_exact_producer(self, monkeypatch):
|
||||
"""A relayed non-head run approval must wake only its own parked waiter."""
|
||||
from api import route_approvals as ra
|
||||
from api import routes as r
|
||||
|
||||
sid = f"identity-v1-non-head-{uuid.uuid4().hex[:8]}"
|
||||
run_id = f"run-identity-v1-{uuid.uuid4().hex[:8]}"
|
||||
head = {
|
||||
"approval_id": "head-approval",
|
||||
"run_id": run_id,
|
||||
"command": "head command",
|
||||
"_gateway_agent_identity_v1": True,
|
||||
}
|
||||
target = {
|
||||
"approval_id": "target-approval",
|
||||
"run_id": run_id,
|
||||
"command": "target command",
|
||||
"_gateway_agent_identity_v1": True,
|
||||
}
|
||||
head_entry = _ApprovalEntry(dict(head))
|
||||
target_entry = _ApprovalEntry(dict(target))
|
||||
captured = {}
|
||||
relays = []
|
||||
|
||||
def fake_j(_handler, data, status=200, extra_headers=None):
|
||||
captured.update(payload=data, status=status)
|
||||
return data
|
||||
|
||||
def fake_respond(_self, got_run_id, got_approval_id, choice):
|
||||
relays.append((got_run_id, got_approval_id, choice))
|
||||
return {"resolved": 1}
|
||||
|
||||
monkeypatch.setattr(r, "j", fake_j)
|
||||
monkeypatch.setattr("api.runner_client.HttpRunnerClient.respond_approval", fake_respond)
|
||||
monkeypatch.setattr(
|
||||
"api.config.gateway_supports_approval_identity_v1", lambda *_args: True
|
||||
)
|
||||
with _lock:
|
||||
r._pending.pop(sid, None)
|
||||
r._gateway_queues[sid] = [head_entry, target_entry]
|
||||
try:
|
||||
ra.submit_gateway_pending_mirror(sid, dict(target_entry.data))
|
||||
mirror = ra.gateway_pending_mirror(
|
||||
sid, approval_id=target["approval_id"], run_id=run_id
|
||||
)
|
||||
assert mirror is not None
|
||||
|
||||
r._handle_approval_respond(
|
||||
object(),
|
||||
{
|
||||
"session_id": sid,
|
||||
"choice": "deny",
|
||||
"approval_id": target["approval_id"],
|
||||
"run_id": run_id,
|
||||
"mirror_token": mirror[ra._GATEWAY_MIRROR_TOKEN],
|
||||
},
|
||||
)
|
||||
|
||||
assert captured == {
|
||||
"payload": {"ok": True, "choice": "deny", "relayed": True},
|
||||
"status": 200,
|
||||
}
|
||||
assert relays == [(run_id, target["approval_id"], "deny")]
|
||||
assert target_entry.event.is_set()
|
||||
assert target_entry.result == "deny"
|
||||
assert not head_entry.event.is_set()
|
||||
with _lock:
|
||||
assert r._gateway_queues[sid] == [head_entry]
|
||||
for _ in range(2):
|
||||
with _lock:
|
||||
ra.reconcile_gateway_pending_mirror_locked(sid)
|
||||
assert ra.gateway_pending_mirror(
|
||||
sid, approval_id=target["approval_id"], run_id=run_id
|
||||
) is None
|
||||
finally:
|
||||
with _lock:
|
||||
r._pending.pop(sid, None)
|
||||
r._gateway_queues.pop(sid, None)
|
||||
|
||||
def test_gateway_mirror_without_run_id_with_one_producer_resolves_exactly(self, monkeypatch):
|
||||
"""A no-run mirror retires only after its exact local producer resolves."""
|
||||
from api import routes as r
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""A wake-up refused for a transient reason the WebUI did not cause must not
|
||||
consume the durable delivery budget.
|
||||
|
||||
Scenario: while the Agent runtime is being replaced, the still-running WebUI
|
||||
answers every server-side wake-up with ``409 agent_runtime_stale
|
||||
retryable=True``. Releasing the claim as a plain failure each time exhausts the
|
||||
bounded budget within seconds and terminally drops a completion whose origin
|
||||
session is alive and waiting.
|
||||
|
||||
Contract under test (``api/background_process._start_async_delegation_wakeup_turn``):
|
||||
|
||||
* a refusal whose payload says ``retryable: True`` (stale runtime), a paused
|
||||
wake-up or a session busy with another stream releases the claim with
|
||||
``retryable=True`` so the core refunds the attempt;
|
||||
* every other rejection keeps the historical bounded behaviour;
|
||||
* an older core whose ``release_event_delivery`` has no ``retryable`` keyword
|
||||
still gets a plain release (compatibility, never an exception).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from api import background_process as bp
|
||||
from api import config as cfg
|
||||
from api import process_event_utils as peu
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout=3.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return bool(predicate())
|
||||
|
||||
|
||||
def _reset() -> None:
|
||||
cfg.PROCESS_SESSION_INDEX.clear()
|
||||
cfg.PENDING_BG_TASK_COMPLETIONS.clear()
|
||||
cfg.BG_TASK_COMPLETE_EVENTS_SEEN.clear()
|
||||
cfg.DEFERRED_PROCESS_WAKEUPS.clear()
|
||||
reset = getattr(peu, "_reset_legacy_async_delivery_dedupe_for_tests", None)
|
||||
if reset is not None:
|
||||
reset()
|
||||
|
||||
|
||||
def _event():
|
||||
return {
|
||||
"type": "async_delegation",
|
||||
"delegation_id": "deleg_retryable",
|
||||
"session_key": "webui-session-1",
|
||||
"status": "completed",
|
||||
"is_batch": True,
|
||||
"results": [{"task_index": 0, "status": "completed", "summary": "verdict"}],
|
||||
"dispatched_at": time.time() - 2,
|
||||
"completed_at": time.time(),
|
||||
}
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self):
|
||||
import queue
|
||||
|
||||
self.completion_queue = queue.Queue()
|
||||
|
||||
|
||||
def _install_core(monkeypatch, *, supports_retryable: bool):
|
||||
calls = {"release": [], "complete": []}
|
||||
mod = types.ModuleType("tools.async_delegation")
|
||||
mod.claim_event_delivery = lambda evt, consumer: f"claim:{consumer}"
|
||||
mod.complete_event_delivery = lambda evt, claim_id: calls["complete"].append(claim_id)
|
||||
mod.get_durable_delegation = lambda delegation_id: {"delivery_state": "pending", "delivery_attempts": 1}
|
||||
mod.restore_undelivered_completions = lambda queue_: 0
|
||||
if supports_retryable:
|
||||
def _release(evt, claim_id, *, retryable=False):
|
||||
calls["release"].append((claim_id, retryable))
|
||||
else:
|
||||
def _release(evt, claim_id): # pre-refund core: no keyword at all
|
||||
calls["release"].append((claim_id, None))
|
||||
mod.release_event_delivery = _release
|
||||
pkg = sys.modules.get("tools") or types.ModuleType("tools")
|
||||
monkeypatch.setitem(sys.modules, "tools", pkg)
|
||||
monkeypatch.setitem(sys.modules, "tools.async_delegation", mod)
|
||||
monkeypatch.setattr(pkg, "async_delegation", mod, raising=False)
|
||||
return calls
|
||||
|
||||
|
||||
def _run(monkeypatch, response, *, supports_retryable=True):
|
||||
_reset()
|
||||
calls = _install_core(monkeypatch, supports_retryable=supports_retryable)
|
||||
from api import routes
|
||||
|
||||
monkeypatch.setattr(routes, "start_session_turn", lambda *a, **k: response)
|
||||
monkeypatch.setattr(bp, "ASYNC_DELIVERY_ROUTING_RETRY_SECONDS", 10.0)
|
||||
registry = _Registry()
|
||||
evt = _event()
|
||||
claim = peu.claim_async_delegation_delivery(evt, "webui-background")
|
||||
assert claim is not None
|
||||
try:
|
||||
bp._start_async_delegation_wakeup_turn(
|
||||
"webui-session-1", "verdict", delegation_id="deleg_retryable",
|
||||
evt=evt, claim=claim, process_registry=registry,
|
||||
)
|
||||
assert _wait_until(lambda: len(calls["release"]) == 1)
|
||||
assert calls["complete"] == []
|
||||
return calls["release"][0]
|
||||
finally:
|
||||
_reset()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("response", [
|
||||
{"_status": 409, "error": "runtime changed", "type": "agent_runtime_stale", "retryable": True},
|
||||
{"_status": 503, "error": "temporarily unavailable", "retryable": True},
|
||||
{"_status": 409, "error": "process_wakeup_paused"},
|
||||
{"_status": 409, "error": "session already has an active stream", "active_stream_id": "s1"},
|
||||
])
|
||||
def test_transient_refusal_releases_with_refund(monkeypatch, response):
|
||||
_claim, retryable = _run(monkeypatch, response)
|
||||
assert retryable is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("response", [
|
||||
{"_status": 500, "error": "boom"},
|
||||
{"_status": 404, "error": "Session not found"},
|
||||
{"_status": 400, "error": "message is required"},
|
||||
{"_status": 409, "error": "conflict", "retryable": False},
|
||||
None,
|
||||
])
|
||||
def test_hard_rejection_keeps_bounded_budget(monkeypatch, response):
|
||||
_claim, retryable = _run(monkeypatch, response)
|
||||
assert retryable is False
|
||||
|
||||
|
||||
def test_old_core_without_retryable_keyword_still_releases(monkeypatch):
|
||||
_claim, retryable = _run(
|
||||
monkeypatch,
|
||||
{"_status": 409, "error": "x", "type": "agent_runtime_stale", "retryable": True},
|
||||
supports_retryable=False,
|
||||
)
|
||||
assert retryable is None # plain release happened, no TypeError escaped
|
||||
|
||||
|
||||
def test_real_core_signature_is_detected():
|
||||
def new_core(evt, claim_id, *, retryable=False):
|
||||
return None
|
||||
|
||||
def kwargs_core(evt, claim_id, **kwargs):
|
||||
return None
|
||||
|
||||
def old_core(evt, claim_id):
|
||||
return None
|
||||
|
||||
assert peu._release_accepts_retryable(new_core) is True
|
||||
assert peu._release_accepts_retryable(kwargs_core) is True
|
||||
assert peu._release_accepts_retryable(old_core) is False
|
||||
@@ -161,6 +161,10 @@ def _reset_wakeup_state() -> None:
|
||||
cfg.PENDING_BG_TASK_COMPLETIONS.clear()
|
||||
cfg.BG_TASK_COMPLETE_EVENTS_SEEN.clear()
|
||||
cfg.DEFERRED_PROCESS_WAKEUPS.clear()
|
||||
# Clear any per-origin wakeup-admission reservations left by a test that
|
||||
# monkeypatched the wakeup runner (which normally releases them).
|
||||
with bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_LOCK:
|
||||
bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_INFLIGHT.clear()
|
||||
reset = getattr(peu, "_reset_legacy_async_delivery_dedupe_for_tests", None)
|
||||
if reset is not None:
|
||||
reset()
|
||||
@@ -1294,3 +1298,331 @@ def test_next_turn_drain_respects_origin_over_session_key_index(monkeypatch):
|
||||
assert delivery["release"] == []
|
||||
|
||||
|
||||
def test_concurrent_idle_wakeups_share_one_atomic_admission(monkeypatch):
|
||||
"""Issue #6959 regression: N simultaneous idle completions for one origin
|
||||
must not each claim the durable delivery. The busy pre-check is not a
|
||||
reservation — multiple completions can pass it together and race for a
|
||||
single turn-admission slot; sibling 409s would then consume the finite
|
||||
delivery-attempt budget. Only one completion may own the in-flight wakeup
|
||||
admission; siblings defer WITHOUT claiming (zero attempts consumed)."""
|
||||
_reset_wakeup_state()
|
||||
registry = _install_fake_process_registry(monkeypatch)
|
||||
delivery = _install_fake_durable_delivery_api(monkeypatch)
|
||||
from api import routes
|
||||
|
||||
turn_started = threading.Event()
|
||||
allow_turn = threading.Event()
|
||||
turn_calls: list[str] = []
|
||||
|
||||
def _start_turn(session_id, prompt, **_kwargs):
|
||||
turn_calls.append(session_id)
|
||||
turn_started.set()
|
||||
assert allow_turn.wait(timeout=3), "first wakeup never unblocked"
|
||||
return {"_status": 200, "stream_id": "stream-1"}
|
||||
|
||||
monkeypatch.setattr(routes, "start_session_turn", _start_turn)
|
||||
monkeypatch.setattr(bp, "_session_has_active_turn", lambda _session_id: False)
|
||||
monkeypatch.setattr(bp, "_emit_bg_task_complete_events_coalesced", lambda *_args: 1)
|
||||
monkeypatch.setattr(bp, "ASYNC_DELIVERY_ROUTING_RETRY_SECONDS", 30.0)
|
||||
|
||||
events = [
|
||||
_async_delegation_event(delegation_id=f"deleg_race_{i}")
|
||||
for i in range(3)
|
||||
]
|
||||
try:
|
||||
# First completion reserves the per-origin admission and claims.
|
||||
bp._process_async_delegation_event(
|
||||
events[0],
|
||||
session_id="webui-session-1",
|
||||
delegation_id=events[0]["delegation_id"],
|
||||
process_registry=registry,
|
||||
)
|
||||
assert _wait_until(turn_started.is_set), "first wakeup never started a turn"
|
||||
|
||||
# Siblings arrive while the first wakeup is still in flight: they must
|
||||
# defer WITHOUT claiming (no delivery attempt consumed).
|
||||
for evt in events[1:]:
|
||||
bp._process_async_delegation_event(
|
||||
evt,
|
||||
session_id="webui-session-1",
|
||||
delegation_id=evt["delegation_id"],
|
||||
process_registry=registry,
|
||||
)
|
||||
|
||||
assert len(turn_calls) == 1, "at most one wakeup turn per origin"
|
||||
assert len(delivery["claim"]) == 1, (
|
||||
"siblings must not claim while admission is held"
|
||||
)
|
||||
assert delivery["claim"][0][0]["delegation_id"] == "deleg_race_0"
|
||||
assert delivery["delivery_attempts"] == 1
|
||||
assert delivery["complete"] == []
|
||||
assert delivery["release"] == []
|
||||
assert delivery["delivery_state"] == "pending"
|
||||
|
||||
# Release admission: exactly the one admitted wakeup delivers once.
|
||||
allow_turn.set()
|
||||
assert _wait_until(lambda: len(delivery["complete"]) == 1)
|
||||
assert delivery["delivery_attempts"] == 1
|
||||
assert delivery["release"] == []
|
||||
assert delivery["delivery_state"] == "delivered"
|
||||
assert len(turn_calls) == 1
|
||||
# Sibling deferrals armed a durable restore sweep (not dropped).
|
||||
assert peu.async_delivery_retry_timer_count() >= 1
|
||||
finally:
|
||||
allow_turn.set()
|
||||
_reset_wakeup_state()
|
||||
|
||||
|
||||
def test_idle_wakeup_admission_rotates_after_transient_rejection(monkeypatch):
|
||||
"""Issue #6959: when the admitted wakeup loses the turn (transient 409),
|
||||
only ITS OWN claim is consumed; the per-origin admission slot rotates so
|
||||
the next backlogged completion claims and delivers on the next
|
||||
opportunity. No row reaches the terminal dropped state."""
|
||||
_reset_wakeup_state()
|
||||
registry = _install_fake_process_registry(monkeypatch)
|
||||
delivery = _install_fake_durable_delivery_api(monkeypatch)
|
||||
from api import routes
|
||||
|
||||
turn_started = threading.Event()
|
||||
allow_turn = threading.Event()
|
||||
round_ = {"n": 0}
|
||||
|
||||
def _start_turn(session_id, prompt, **_kwargs):
|
||||
turn_started.set()
|
||||
assert allow_turn.wait(timeout=3), "wakeup never unblocked"
|
||||
round_["n"] += 1
|
||||
if round_["n"] == 1:
|
||||
return {"_status": 409, "error": "busy"} # lost turn admission
|
||||
return {"_status": 200, "stream_id": "stream-1"}
|
||||
|
||||
monkeypatch.setattr(routes, "start_session_turn", _start_turn)
|
||||
monkeypatch.setattr(bp, "_session_has_active_turn", lambda _session_id: False)
|
||||
monkeypatch.setattr(bp, "_emit_bg_task_complete_events_coalesced", lambda *_args: 1)
|
||||
monkeypatch.setattr(bp, "ASYNC_DELIVERY_ROUTING_RETRY_SECONDS", 30.0)
|
||||
|
||||
def _admission_empty() -> bool:
|
||||
with bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_LOCK:
|
||||
return not bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_INFLIGHT
|
||||
|
||||
evt_first = _async_delegation_event(delegation_id="deleg_rotate_0")
|
||||
evt_second = _async_delegation_event(delegation_id="deleg_rotate_1")
|
||||
try:
|
||||
# Round 1: the admitted wakeup is transiently rejected. Its release
|
||||
# must consume exactly one attempt and free the admission slot.
|
||||
bp._process_async_delegation_event(
|
||||
evt_first,
|
||||
session_id="webui-session-1",
|
||||
delegation_id=evt_first["delegation_id"],
|
||||
process_registry=registry,
|
||||
)
|
||||
assert _wait_until(turn_started.is_set)
|
||||
allow_turn.set()
|
||||
assert _wait_until(lambda: len(delivery["release"]) == 1)
|
||||
assert delivery["delivery_attempts"] == 1
|
||||
assert delivery["delivery_state"] == "pending"
|
||||
assert round_["n"] == 1
|
||||
assert _wait_until(_admission_empty), "slot must rotate after rejection"
|
||||
|
||||
# Round 2: the next backlogged completion claims and is accepted.
|
||||
turn_started.clear()
|
||||
bp._process_async_delegation_event(
|
||||
evt_second,
|
||||
session_id="webui-session-1",
|
||||
delegation_id=evt_second["delegation_id"],
|
||||
process_registry=registry,
|
||||
)
|
||||
assert _wait_until(turn_started.is_set)
|
||||
allow_turn.set()
|
||||
assert _wait_until(lambda: len(delivery["complete"]) == 1)
|
||||
assert delivery["delivery_state"] == "delivered"
|
||||
assert delivery["delivery_attempts"] == 2
|
||||
assert len(delivery["release"]) == 1
|
||||
assert round_["n"] == 2
|
||||
assert _wait_until(_admission_empty)
|
||||
finally:
|
||||
allow_turn.set()
|
||||
_reset_wakeup_state()
|
||||
|
||||
|
||||
def test_admission_slot_released_on_claim_and_format_failures(monkeypatch):
|
||||
"""Issue #6959 guard: every pre-dispatch exit of the delivery path must
|
||||
release the per-origin admission reservation, or a failed claim/format
|
||||
would block all future wakeups for that session."""
|
||||
_reset_wakeup_state()
|
||||
registry = _install_fake_process_registry(monkeypatch)
|
||||
delivery = _install_fake_durable_delivery_api(monkeypatch)
|
||||
from api import routes
|
||||
|
||||
accepted = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"start_session_turn",
|
||||
lambda *_args, **_kwargs: accepted.append(1)
|
||||
or {"_status": 200, "stream_id": "stream-1"},
|
||||
)
|
||||
monkeypatch.setattr(bp, "_session_has_active_turn", lambda _session_id: False)
|
||||
monkeypatch.setattr(bp, "_emit_bg_task_complete_events_coalesced", lambda *_args: 1)
|
||||
monkeypatch.setattr(bp, "ASYNC_DELIVERY_ROUTING_RETRY_SECONDS", 30.0)
|
||||
|
||||
def _admission_empty() -> bool:
|
||||
with bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_LOCK:
|
||||
return not bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_INFLIGHT
|
||||
|
||||
try:
|
||||
# Claim returns None (row already delivered): slot must be released.
|
||||
delivery["delivery_state"] = "delivered"
|
||||
bp._process_async_delegation_event(
|
||||
_async_delegation_event(delegation_id="deleg_noclaim"),
|
||||
session_id="webui-session-1",
|
||||
delegation_id="deleg_noclaim",
|
||||
process_registry=registry,
|
||||
)
|
||||
assert _admission_empty(), "claim-None exit must release the admission slot"
|
||||
|
||||
# Formatting failure (empty prompt): slot must be released.
|
||||
delivery["delivery_state"] = "pending"
|
||||
monkeypatch.setattr(bp, "format_wakeup_prompt", lambda _evt: "")
|
||||
bp._process_async_delegation_event(
|
||||
_async_delegation_event(delegation_id="deleg_badfmt"),
|
||||
session_id="webui-session-1",
|
||||
delegation_id="deleg_badfmt",
|
||||
process_registry=registry,
|
||||
)
|
||||
assert _admission_empty(), "format-failure exit must release the admission slot"
|
||||
|
||||
# A normal wakeup for the same session still claims and delivers.
|
||||
monkeypatch.setattr(bp, "format_wakeup_prompt", lambda _evt: "delegation result")
|
||||
bp._process_async_delegation_event(
|
||||
_async_delegation_event(delegation_id="deleg_ok"),
|
||||
session_id="webui-session-1",
|
||||
delegation_id="deleg_ok",
|
||||
process_registry=registry,
|
||||
)
|
||||
assert _wait_until(lambda: len(delivery["complete"]) == 1)
|
||||
assert delivery["delivery_state"] == "delivered"
|
||||
assert accepted
|
||||
finally:
|
||||
_reset_wakeup_state()
|
||||
|
||||
|
||||
def test_busy_predicate_covers_stream_publication_window_before_active_runs(
|
||||
monkeypatch,
|
||||
):
|
||||
"""#6959 gate (MUST-FIX 1 repro): a worker blocked *before* ACTIVE_RUNS
|
||||
registration — stream already published (STREAMS + STREAM_SESSION_OWNERS
|
||||
populated), worker thread not yet in ACTIVE_RUNS — must still count as an
|
||||
active turn. Otherwise a sibling same-origin completion would pass the busy
|
||||
pre-check, reserve, claim, and 409 against the already-published stream,
|
||||
burning the finite delivery-attempt budget. With the fix, completions that
|
||||
arrive in that window defer WITHOUT claiming (zero claims, zero attempts)."""
|
||||
_reset_wakeup_state()
|
||||
registry = _install_fake_process_registry(monkeypatch)
|
||||
delivery = _install_fake_durable_delivery_api(monkeypatch)
|
||||
# ACTIVE_RUNS is empty: the worker has NOT yet registered — only the
|
||||
# pre-registration stream publication is visible.
|
||||
monkeypatch.setattr(cfg, "ACTIVE_RUNS", {})
|
||||
monkeypatch.setattr(cfg, "STREAMS", {"stream-preactive": object()})
|
||||
monkeypatch.setattr(
|
||||
cfg,
|
||||
"STREAM_SESSION_OWNERS",
|
||||
{"stream-preactive": "webui-session-1"},
|
||||
)
|
||||
monkeypatch.setattr(bp, "_emit_bg_task_complete_events_coalesced", lambda *_args: 1)
|
||||
monkeypatch.setattr(bp, "ASYNC_DELIVERY_ROUTING_RETRY_SECONDS", 30.0)
|
||||
|
||||
try:
|
||||
# The real predicate (not monkeypatched) must see the published stream.
|
||||
assert bp._session_has_active_turn("webui-session-1") is True
|
||||
# Cross-origin isolation: another session is not blocked by it.
|
||||
assert bp._session_has_active_turn("other-session") is False
|
||||
|
||||
# A completion for the origin arrives in the publication window: it
|
||||
# must defer via the busy path — zero claims, zero delivery attempts.
|
||||
bp._process_async_delegation_event(
|
||||
_async_delegation_event(delegation_id="deleg_preactive"),
|
||||
session_id="webui-session-1",
|
||||
delegation_id="deleg_preactive",
|
||||
process_registry=registry,
|
||||
)
|
||||
assert delivery["claim"] == [], (
|
||||
"no claim may be taken while the pre-ACTIVE_RUNS stream is live"
|
||||
)
|
||||
assert delivery["delivery_attempts"] == 0
|
||||
assert delivery["release"] == []
|
||||
with bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_LOCK:
|
||||
assert bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_INFLIGHT == set(), (
|
||||
"busy-path exit must not leave a reservation behind"
|
||||
)
|
||||
assert delivery["delivery_state"] == "pending"
|
||||
finally:
|
||||
_reset_wakeup_state()
|
||||
|
||||
|
||||
def test_admission_slot_released_when_retry_helper_raises(monkeypatch):
|
||||
"""#6959 gate (MUST-FIX 2 repro): the post-reservation body must release
|
||||
the per-origin admission even when a retry helper raises mid-path — the
|
||||
old code released manually AFTER schedule_async_delegation_claim_retry, so
|
||||
a raise there stranded the reservation permanently and every later
|
||||
completion for the origin deferred forever without delivery."""
|
||||
_reset_wakeup_state()
|
||||
registry = _install_fake_process_registry(monkeypatch)
|
||||
delivery = _install_fake_durable_delivery_api(monkeypatch)
|
||||
monkeypatch.setattr(bp, "_session_has_active_turn", lambda _session_id: False)
|
||||
monkeypatch.setattr(bp, "ASYNC_DELIVERY_ROUTING_RETRY_SECONDS", 30.0)
|
||||
|
||||
def _admission_empty() -> bool:
|
||||
with bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_LOCK:
|
||||
return not bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_INFLIGHT
|
||||
|
||||
try:
|
||||
# Claim succeeds, then the claim-None retry scheduler raises: the
|
||||
# exception must NOT strand the reservation.
|
||||
delivery["delivery_state"] = "delivered" # claim returns None
|
||||
monkeypatch.setattr(
|
||||
bp,
|
||||
"schedule_async_delegation_claim_retry",
|
||||
lambda *a, **k: (_ for _ in ()).throw(
|
||||
RuntimeError("retry scheduler boom")
|
||||
),
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
bp._process_async_delegation_event(
|
||||
_async_delegation_event(delegation_id="deleg_raise_claim"),
|
||||
session_id="webui-session-1",
|
||||
delegation_id="deleg_raise_claim",
|
||||
process_registry=registry,
|
||||
)
|
||||
assert _admission_empty(), (
|
||||
"reservation must be released even when the retry scheduler raises"
|
||||
)
|
||||
|
||||
# The session is NOT bricked: a later completion still claims and
|
||||
# delivers normally (real wakeup thread; its own finally releases).
|
||||
from api import routes
|
||||
|
||||
delivery["delivery_state"] = "pending"
|
||||
monkeypatch.setattr(
|
||||
bp,
|
||||
"schedule_async_delegation_claim_retry",
|
||||
lambda *a, **k: False,
|
||||
)
|
||||
monkeypatch.setattr(bp, "format_wakeup_prompt", lambda _evt: "delegation result")
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"start_session_turn",
|
||||
lambda *_args, **_kwargs: {"_status": 200, "stream_id": "stream-1"},
|
||||
)
|
||||
bp._process_async_delegation_event(
|
||||
_async_delegation_event(delegation_id="deleg_raise_ok"),
|
||||
session_id="webui-session-1",
|
||||
delegation_id="deleg_raise_ok",
|
||||
process_registry=registry,
|
||||
)
|
||||
assert _wait_until(lambda: len(delivery["complete"]) == 1)
|
||||
assert delivery["delivery_state"] == "delivered"
|
||||
assert _wait_until(_admission_empty)
|
||||
finally:
|
||||
_reset_wakeup_state()
|
||||
|
||||
|
||||
@@ -1076,9 +1076,10 @@ def test_context_anchor_reference_uses_session_summary_fallback():
|
||||
|
||||
assert "sessionCompressionSummary" in src
|
||||
assert "const sessionCompressionSummary" in src
|
||||
assert "referenceText=referenceMessage" in src
|
||||
assert ": sessionCompressionSummary" in src
|
||||
assert "_shouldShowSettledCompressionReference(referenceText)" in src
|
||||
assert "const referenceText=(()=>{" in src
|
||||
assert "if(!referenceMessage) return sessionCompressionSummary;" in src
|
||||
assert "return segment!==null?segment:raw;" in src
|
||||
assert "referenceMessageRawIdx<0 && _shouldShowSettledCompressionReference(referenceText)" in src
|
||||
assert "!_isContextCompactionText(referenceText)" in src
|
||||
|
||||
|
||||
@@ -1115,8 +1116,9 @@ def test_reference_message_uses_raw_transcript_position_before_anchor_fallback()
|
||||
src = _read("static/ui.js")
|
||||
|
||||
assert "const {message:referenceMessage, rawIdx:referenceMessageRawIdx}=_latestCompressionReferenceMessage(" in src
|
||||
assert "if(referenceNode&&referenceMessageRawIdx>=0) _insertCompressionLikeNodeByRawIdx(referenceNode, referenceMessageRawIdx);" in src
|
||||
assert "else _insertCompressionLikeNode(referenceNode);" in src
|
||||
assert "referenceNode&&referenceMessageRawIdx>=0" in src
|
||||
assert "?_insertCompressionLikeNodeByRawIdx(referenceNode,referenceMessageRawIdx)" in src
|
||||
assert ":_insertCompressionLikeNode(referenceNode);" in src
|
||||
|
||||
|
||||
def test_reference_message_inserted_before_future_assistant_anchor():
|
||||
@@ -1199,7 +1201,7 @@ def test_frontend_reference_insertion_skips_when_reference_is_before_render_wind
|
||||
assert end != -1, "raw-index insertion helper end not found"
|
||||
helper = src[start:end]
|
||||
|
||||
assert "if(rawIdx<firstRenderedRawIdx) return;" in helper
|
||||
assert "if(rawIdx<firstRenderedRawIdx) return false;" in helper
|
||||
|
||||
|
||||
def test_reference_message_selection_prefers_latest_matching_marker():
|
||||
@@ -1227,16 +1229,14 @@ def test_reference_message_falls_back_to_current_summary_when_only_stale_markers
|
||||
assert "return {message:null, rawIdx:-1};" in helper
|
||||
|
||||
|
||||
def test_preserved_task_list_attaches_once_per_render():
|
||||
def test_preserved_task_list_source_uses_latest_snapshot():
|
||||
src = _read("static/ui.js")
|
||||
|
||||
assert "function _latestPreservedCompressionTaskListMessages" in src
|
||||
assert ".reverse().find(m=>_isPreservedCompressionTaskListMessage(m))" in src
|
||||
assert "const preservedCompressionTaskMessages=_latestPreservedCompressionTaskListMessages(S.messages);" in src
|
||||
assert "S.messages.filter(m=>_isPreservedCompressionTaskListMessage(m))" not in src
|
||||
assert "let preservedCompressionTaskCardsAttached=!!referenceNode;" in src
|
||||
assert "const preservedOnlyNode=" in src
|
||||
assert "(!preservedCompressionTaskCardsAttached&&(!referenceNode||compressionState)&&preservedCompressionTaskMessages.length)" in src
|
||||
|
||||
|
||||
|
||||
def test_preserved_task_list_is_suppressed_when_latest_todo_state_has_no_active_items():
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Regression coverage for notify_on_complete across a WebUI restart."""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
@@ -158,4 +159,103 @@ def test_recovery_is_fail_soft_without_agent(monkeypatch):
|
||||
monkeypatch.setattr("builtins.__import__", fake_import)
|
||||
|
||||
assert bp.recover_processes_for_webui() == 0
|
||||
assert bp._PROCESS_RECOVERY_DONE is False
|
||||
assert bp._PROCESS_RECOVERY_DONE is False
|
||||
|
||||
|
||||
def test_recovery_skips_process_whose_session_is_gone(monkeypatch, caplog):
|
||||
"""A checkpointed process whose owner has been pruned (the resolver's
|
||||
``KeyError(sid)`` contract) must be silently skipped, while a neighbouring
|
||||
live process in the same recovery sweep still rebinds. Regression for
|
||||
issue #7753: previously every vanished owner logged a full WARNING
|
||||
traceback, indistinguishable from a real fault.
|
||||
"""
|
||||
calls = {"registered": []}
|
||||
|
||||
class FakeRegistry:
|
||||
def recover_from_checkpoint(self):
|
||||
return 2 # two checkpointed processes survive restart
|
||||
|
||||
def list_sessions(self):
|
||||
return [
|
||||
{"session_id": "proc_vanished", "detached": True},
|
||||
{"session_id": "proc_live", "detached": True},
|
||||
]
|
||||
|
||||
def get(self, process_id):
|
||||
if process_id == "proc_vanished":
|
||||
return _FakeProcessSession("vanished-session")
|
||||
if process_id == "proc_live":
|
||||
return _FakeProcessSession("live-session")
|
||||
raise AssertionError(process_id)
|
||||
|
||||
fake_registry = FakeRegistry()
|
||||
monkeypatch.setattr(bp, "_PROCESS_CHECKPOINT_RECOVERED", False)
|
||||
monkeypatch.setattr(bp, "_PROCESS_RECOVERY_DONE", False)
|
||||
monkeypatch.setattr(
|
||||
bp,
|
||||
"register_process_session",
|
||||
lambda key, sid: calls["registered"].append((key, sid)),
|
||||
)
|
||||
|
||||
def get_session(sid, metadata_only=False):
|
||||
# Mirrors api.models._resolve_session_once's contract: missing
|
||||
# sessions raise KeyError, NOT None.
|
||||
if sid == "vanished-session":
|
||||
raise KeyError(sid)
|
||||
return SimpleNamespace(id=sid)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="api.background_process"):
|
||||
result = bp.recover_processes_for_webui(fake_registry, get_session)
|
||||
|
||||
assert result == 2
|
||||
# The live process is rebound; the vanished one is silently skipped.
|
||||
assert calls["registered"] == [("live-session", "live-session")]
|
||||
# No WARNING emitted for the vanished-owner case.
|
||||
warning_records = [
|
||||
r for r in caplog.records if r.levelno >= logging.WARNING
|
||||
]
|
||||
assert warning_records == [], (
|
||||
"vanished-session KeyError must not produce a WARNING, got: "
|
||||
f"{[r.getMessage() for r in warning_records]}"
|
||||
)
|
||||
assert "Could not resolve recovered WebUI process" not in caplog.text
|
||||
|
||||
|
||||
def test_recovery_still_warns_on_unexpected_resolve_failure(monkeypatch, caplog):
|
||||
"""Non-KeyError failures from the resolver (e.g. ``OSError`` on an
|
||||
unreadable sidecar) must keep entering the existing WARNING path. This
|
||||
pins that the fix for #7753 is narrow and does not over-broaden the
|
||||
silent-skip contract.
|
||||
"""
|
||||
class FakeRegistry:
|
||||
def recover_from_checkpoint(self):
|
||||
return 1
|
||||
|
||||
def list_sessions(self):
|
||||
return [{"session_id": "proc_unexpected", "detached": True}]
|
||||
|
||||
def get(self, process_id):
|
||||
assert process_id == "proc_unexpected"
|
||||
return _FakeProcessSession("webui-session")
|
||||
|
||||
fake_registry = FakeRegistry()
|
||||
monkeypatch.setattr(bp, "_PROCESS_CHECKPOINT_RECOVERED", False)
|
||||
monkeypatch.setattr(bp, "_PROCESS_RECOVERY_DONE", False)
|
||||
|
||||
def get_session(sid, metadata_only=False):
|
||||
raise OSError("sidecar unreadable")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="api.background_process"):
|
||||
result = bp.recover_processes_for_webui(fake_registry, get_session)
|
||||
|
||||
assert result == 1
|
||||
warning_records = [
|
||||
r for r in caplog.records if r.levelno >= logging.WARNING
|
||||
]
|
||||
assert any(
|
||||
"Could not resolve recovered WebUI process 'proc_unexpected'" in r.getMessage()
|
||||
for r in warning_records
|
||||
), (
|
||||
"OSError from the resolver must still emit a WARNING, got: "
|
||||
f"{[r.getMessage() for r in warning_records]}"
|
||||
)
|
||||
@@ -41,12 +41,13 @@ class TestRootWorkspaceUnblocked:
|
||||
assert "'/etc'" in src or 'Path("/etc")' in src or "Path('/etc')" in src
|
||||
assert "'/proc'" in src or 'Path("/proc")' in src or "Path('/proc')" in src
|
||||
|
||||
def test_split_guard_present(self):
|
||||
src = read("api/streaming.py")
|
||||
assert "'\\n\\n[Attached files:' in msg_text" in src, (
|
||||
"base_text split must guard against missing '[Attached files:' "
|
||||
"to avoid empty-string on plain messages"
|
||||
)
|
||||
def test_attached_files_suffix_is_optional(self):
|
||||
from api.models import _strip_attached_files_marker
|
||||
|
||||
assert _strip_attached_files_marker("Plain message") == "Plain message"
|
||||
assert _strip_attached_files_marker(
|
||||
"Question\n\n[Attached files: photo.png]"
|
||||
) == "Question"
|
||||
|
||||
|
||||
# ── Group B: custom_providers visibility ─────────────────────────────────────
|
||||
|
||||
@@ -197,3 +197,87 @@ class TestBootstrapStructure:
|
||||
assert "_load_repo_dotenv" in bootstrap_src, (
|
||||
"bootstrap.py must load .env so direct invocation matches start.sh behaviour"
|
||||
)
|
||||
|
||||
|
||||
class TestLeakedWebuiPasswordIsolation:
|
||||
"""#7168 review: a local repo .env containing HERMES_WEBUI_PASSWORD leaks
|
||||
into os.environ when any test imports bootstrap (import-time
|
||||
_load_repo_dotenv() runs OUTSIDE monkeypatch's undo scope). The conftest
|
||||
autouse guard strips the leaked var around every test so later tests
|
||||
don't see is_auth_enabled()==True (the #5588 failure shape)."""
|
||||
|
||||
def _load_guard(self):
|
||||
import importlib.util
|
||||
|
||||
cpath = Path(__file__).resolve().parent / "conftest.py"
|
||||
spec = importlib.util.spec_from_file_location("_test_conftest", cpath)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod._strip_leaked_webui_password_env
|
||||
|
||||
def test_conftest_guard_strips_leaked_password(self):
|
||||
strip = self._load_guard()
|
||||
|
||||
os.environ["HERMES_WEBUI_PASSWORD"] = "leaked-from-repo-dotenv"
|
||||
try:
|
||||
strip()
|
||||
assert "HERMES_WEBUI_PASSWORD" not in os.environ
|
||||
finally:
|
||||
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
|
||||
|
||||
def test_conftest_guard_preserves_intentional_empty_override(self):
|
||||
"""ctl.sh-style override semantics: an explicitly empty value means
|
||||
'keep auth off' and must survive the strip."""
|
||||
strip = self._load_guard()
|
||||
|
||||
sentinel = object()
|
||||
os.environ["HERMES_WEBUI_PASSWORD"] = ""
|
||||
try:
|
||||
strip()
|
||||
assert os.environ.get("HERMES_WEBUI_PASSWORD", sentinel) == ""
|
||||
finally:
|
||||
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
|
||||
|
||||
|
||||
class TestLeakedHermesCommandIsolation:
|
||||
"""#7168 re-gate round 7: a local repo .env carrying HERMES_COMMAND leaks
|
||||
into os.environ via bootstrap import-time _load_repo_dotenv() and
|
||||
redirects gateway_restart._resolve_hermes_command() away from its mocked
|
||||
shutil.which result — every later active-profile-restart test then fails
|
||||
on a machine-specific CLI path. The autouse conftest guard strips the
|
||||
leaked var around every test; upstream code never reads HERMES_COMMAND,
|
||||
so stripping restores exact upstream semantics."""
|
||||
|
||||
def _load_guard(self):
|
||||
import importlib.util
|
||||
|
||||
cpath = Path(__file__).resolve().parent / "conftest.py"
|
||||
spec = importlib.util.spec_from_file_location("_test_conftest_hc", cpath)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod._strip_leaked_webui_password_env
|
||||
|
||||
def test_conftest_guard_strips_leaked_hermes_command(self):
|
||||
strip = self._load_guard()
|
||||
|
||||
os.environ["HERMES_COMMAND"] = "/machine/local/hermes-gateway-wrapper"
|
||||
try:
|
||||
strip()
|
||||
assert "HERMES_COMMAND" not in os.environ
|
||||
finally:
|
||||
os.environ.pop("HERMES_COMMAND", None)
|
||||
|
||||
def test_conftest_guard_still_strips_password_when_command_leaks(self):
|
||||
"""Both leaked vars are stripped in one pass (password branch must
|
||||
not be short-circuited by the HERMES_COMMAND handling)."""
|
||||
strip = self._load_guard()
|
||||
|
||||
os.environ["HERMES_WEBUI_PASSWORD"] = "leaked-from-repo-dotenv"
|
||||
os.environ["HERMES_COMMAND"] = "/machine/local/hermes-gateway-wrapper"
|
||||
try:
|
||||
strip()
|
||||
assert "HERMES_WEBUI_PASSWORD" not in os.environ
|
||||
assert "HERMES_COMMAND" not in os.environ
|
||||
finally:
|
||||
os.environ.pop("HERMES_WEBUI_PASSWORD", None)
|
||||
os.environ.pop("HERMES_COMMAND", None)
|
||||
@@ -227,14 +227,14 @@ class TestServerDisconnectHandling(unittest.TestCase):
|
||||
# Patch handle_get to raise BrokenPipeError
|
||||
import server as _server_mod
|
||||
orig_handle_get = _server_mod.handle_get
|
||||
orig_check_auth = _server_mod.check_auth
|
||||
orig_check_auth = _server_mod.check_auth_or_close
|
||||
_server_mod.handle_get = _fake_handle_get
|
||||
_server_mod.check_auth = lambda h, p: True
|
||||
_server_mod.check_auth_or_close = lambda h, p: True
|
||||
try:
|
||||
Handler.do_GET(handler)
|
||||
finally:
|
||||
_server_mod.handle_get = orig_handle_get
|
||||
_server_mod.check_auth = orig_check_auth
|
||||
_server_mod.check_auth_or_close = orig_check_auth
|
||||
|
||||
# send_response should NEVER be called for the 500 — client is gone
|
||||
handler.send_response.assert_not_called()
|
||||
@@ -248,12 +248,12 @@ class TestServerDisconnectHandling(unittest.TestCase):
|
||||
raise self._route_raises
|
||||
|
||||
import server as _server_mod
|
||||
orig_check_auth = _server_mod.check_auth
|
||||
_server_mod.check_auth = lambda h, p: True
|
||||
orig_check_auth = _server_mod.check_auth_or_close
|
||||
_server_mod.check_auth_or_close = lambda h, p: True
|
||||
try:
|
||||
Handler._handle_write(handler, _fake_route)
|
||||
finally:
|
||||
_server_mod.check_auth = orig_check_auth
|
||||
_server_mod.check_auth_or_close = orig_check_auth
|
||||
|
||||
handler.send_response.assert_not_called()
|
||||
|
||||
@@ -286,9 +286,9 @@ class TestServerDisconnectHandling(unittest.TestCase):
|
||||
|
||||
import server as _server_mod
|
||||
orig_handle_get = _server_mod.handle_get
|
||||
orig_check_auth = _server_mod.check_auth
|
||||
orig_check_auth = _server_mod.check_auth_or_close
|
||||
_server_mod.handle_get = _fake_handle_get
|
||||
_server_mod.check_auth = lambda h, p: True
|
||||
_server_mod.check_auth_or_close = lambda h, p: True
|
||||
|
||||
closed_stdout = io.StringIO()
|
||||
closed_stdout.close()
|
||||
@@ -299,7 +299,7 @@ class TestServerDisconnectHandling(unittest.TestCase):
|
||||
finally:
|
||||
sys.stdout = original_stdout
|
||||
_server_mod.handle_get = orig_handle_get
|
||||
_server_mod.check_auth = orig_check_auth
|
||||
_server_mod.check_auth_or_close = orig_check_auth
|
||||
|
||||
handler.send_response.assert_called_once_with(500)
|
||||
|
||||
@@ -312,14 +312,14 @@ class TestServerDisconnectHandling(unittest.TestCase):
|
||||
|
||||
import server as _server_mod
|
||||
orig_handle_get = _server_mod.handle_get
|
||||
orig_check_auth = _server_mod.check_auth
|
||||
orig_check_auth = _server_mod.check_auth_or_close
|
||||
_server_mod.handle_get = _fake_handle_get
|
||||
_server_mod.check_auth = lambda h, p: True
|
||||
_server_mod.check_auth_or_close = lambda h, p: True
|
||||
try:
|
||||
Handler.do_GET(handler)
|
||||
finally:
|
||||
_server_mod.handle_get = orig_handle_get
|
||||
_server_mod.check_auth = orig_check_auth
|
||||
_server_mod.check_auth_or_close = orig_check_auth
|
||||
|
||||
# Should send 500 for real errors
|
||||
handler.send_response.assert_called_once_with(500)
|
||||
@@ -334,14 +334,14 @@ class TestServerDisconnectHandling(unittest.TestCase):
|
||||
|
||||
import server as _server_mod
|
||||
orig_handle_get = _server_mod.handle_get
|
||||
orig_check_auth = _server_mod.check_auth
|
||||
orig_check_auth = _server_mod.check_auth_or_close
|
||||
_server_mod.handle_get = _fake_handle_get
|
||||
_server_mod.check_auth = lambda h, p: True
|
||||
_server_mod.check_auth_or_close = lambda h, p: True
|
||||
try:
|
||||
Handler.do_GET(handler)
|
||||
finally:
|
||||
_server_mod.handle_get = orig_handle_get
|
||||
_server_mod.check_auth = orig_check_auth
|
||||
_server_mod.check_auth_or_close = orig_check_auth
|
||||
|
||||
handler.send_response.assert_not_called()
|
||||
|
||||
@@ -355,14 +355,14 @@ class TestServerDisconnectHandling(unittest.TestCase):
|
||||
|
||||
import server as _server_mod
|
||||
orig_handle_get = _server_mod.handle_get
|
||||
orig_check_auth = _server_mod.check_auth
|
||||
orig_check_auth = _server_mod.check_auth_or_close
|
||||
_server_mod.handle_get = _fake_handle_get
|
||||
_server_mod.check_auth = lambda h, p: True
|
||||
_server_mod.check_auth_or_close = lambda h, p: True
|
||||
try:
|
||||
Handler.do_GET(handler)
|
||||
finally:
|
||||
_server_mod.handle_get = orig_handle_get
|
||||
_server_mod.check_auth = orig_check_auth
|
||||
_server_mod.check_auth_or_close = orig_check_auth
|
||||
|
||||
handler.send_response.assert_not_called()
|
||||
|
||||
@@ -406,14 +406,14 @@ class TestServer500ResponseSafety(unittest.TestCase):
|
||||
|
||||
import server as _server_mod
|
||||
orig_handle_get = _server_mod.handle_get
|
||||
orig_check_auth = _server_mod.check_auth
|
||||
orig_check_auth = _server_mod.check_auth_or_close
|
||||
_server_mod.handle_get = _fake_handle_get
|
||||
_server_mod.check_auth = lambda h, p: True
|
||||
_server_mod.check_auth_or_close = lambda h, p: True
|
||||
try:
|
||||
Handler.do_GET(handler)
|
||||
finally:
|
||||
_server_mod.handle_get = orig_handle_get
|
||||
_server_mod.check_auth = orig_check_auth
|
||||
_server_mod.check_auth_or_close = orig_check_auth
|
||||
|
||||
# send_response WAS called (we tried to send 500), but write failed
|
||||
handler.send_response.assert_called_once_with(500)
|
||||
@@ -431,14 +431,14 @@ class TestServer500ResponseSafety(unittest.TestCase):
|
||||
|
||||
import server as _server_mod
|
||||
orig_handle_get = _server_mod.handle_get
|
||||
orig_check_auth = _server_mod.check_auth
|
||||
orig_check_auth = _server_mod.check_auth_or_close
|
||||
_server_mod.handle_get = _fake_handle_get
|
||||
_server_mod.check_auth = lambda h, p: True
|
||||
_server_mod.check_auth_or_close = lambda h, p: True
|
||||
try:
|
||||
Handler.do_GET(handler)
|
||||
finally:
|
||||
_server_mod.handle_get = orig_handle_get
|
||||
_server_mod.check_auth = orig_check_auth
|
||||
_server_mod.check_auth_or_close = orig_check_auth
|
||||
|
||||
# send_response WAS called (we tried to send 500), but write failed
|
||||
handler.send_response.assert_called_once_with(500)
|
||||
|
||||
Loaded 100 of 301 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user