Commit Graph
331 Commits
Author SHA1 Message Date
Vincent Dukeandnesquena-hermes 64c2c12df7 feat(sidebar): add show_kanban_sessions toggle to hide kanban worker sessions (#6780)
* feat(sidebar): add show_kanban_sessions toggle to hide kanban worker sessions

* fix(sidebar): kanban source-filter override + filtered-view cap (cron/webhook parity)

Gate re-review (Codex) found the kanban-hide addition missed cron/webhook
parity for the source-filter case:
- An explicit kanban source_filter now overrides the default hide (threaded
  source_filter into _dedupe_cli_sidebar_sessions_for_api, normalized with
  strip().lower() to match the loader), so a deliberate kanban view shows
  those rows even with the toggle off. Scoped to the matching source only.
- A dedicated KANBAN_PROJECT_CHIP_LIMIT bounds a filtered kanban-only view,
  mirroring CRON/WEBHOOK_PROJECT_CHIP_LIMIT.

Deliberately NOT changing the general exclude_sources: the original PR lets
kanban load on the source_filter=None path so the toggle-ON case works (the
hide is applied by _hide_from_default_sidebar only when the toggle is off).
Excluding kanban there would save the 20-row budget but break toggle-on
unless a bounded kanban merge is added (cron/webhook don't do that either) —
that query-budget optimization is deferred as a separate cross-cutting change.
The pinned-survives-hide finding is also out of scope (cron/webhook hide pinned
identically, confirmed by Codex). Adds bite-tested regressions.

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

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-08-09 02:53:25 +00:00
898a01b766 Release exp-v0.52.173: don't mutate system prompt on workspace switch (#6683) (#6841)
* fix(cache): don't mutate system prompt on workspace switch (#6672)

* test(#6672): pin created_workspace freeze + compact round-trip + legacy fallback (closes coverage gap flagged in review)

* Release exp-v0.52.173: don't mutate system prompt on workspace switch (#6683)

---------

Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-08-07 19:42:15 -07:00
Frank Song dadffe0e02 fix: bind gateway terminal recovery to run identity 2026-07-31 17:31:12 +08:00
Frank Song ded12e48c5 fix: recover unsaved gateway terminal errors 2026-07-31 12:05:39 +08:00
pxxD1998 e2b052a637 fix(workspace): persist recovered session bindings 2026-07-29 18:22:22 +00:00
b3nwandb3nw e93b0c352a feat: render process wakeups as collapsed summary cards (#6350)
Render structured background-process completions and watch matches as accessible collapsed cards while preserving raw fallback, verbatim output, and full watch patterns.

Co-authored-by: b3nw <b3nw@duck.com>
2026-07-23 20:52:51 +00:00
b3nw 30aef85b18 feat: stamp per-turn used model in footer stats (#6068)
Record resolved_model on assistant messages as _usedModel, expose used_model
in the usage SSE payload, and render model chips in transparent and settled
turn footers while suppressing duplicates when gateway routing already shows
used_model.
2026-07-21 16:02:49 +00:00
4d9965b37a Release: content search no longer evicts the working-set cache (#6084, @ai-ag2026) (#6343)
* fix(webui): keep the content search from evicting the user's working set

/api/sessions/search?content=1 walks EVERY session and pulls each one through
get_session(), which inserts it into the SESSIONS LRU and marks it
recently-used. On any install with more sessions than sessions_cache_max
(default 300), a single search therefore flushes the whole cache and refills it
with sessions the user is not looking at — the classic buffer-pool
scan-pollution problem. The sessions actually open in the UI are exactly the
ones evicted, and the search is keystroke-debounced, so it repeats while typing.

A scan reads each session exactly once, so nothing it touches has earned
"recently used". get_session_for_scan() reuses a resident session without
promoting it, and reads a cold one straight from disk without caching it. It
returns None rather than raising, since a scan skips what it cannot open.

This is a correctness fix for cache behaviour, not a latency fix. The
multi-second searches that led here were contention, not scan cost: a trivial
/api/profiles took 9.2s in the same window, and a full read+parse of ~1700 real
sessions measures ~4s total.

test_sessions_search_depth_validation patched api.routes.get_session. With the
search reading through the scan accessor it now patches get_session_for_scan —
left unfixed, two of its cases fail and the third passes vacuously against an
empty result set.

Validation:
  pytest tests/test_issue4765_sessions_lru_eviction.py
         tests/test_sessions_search_depth_validation.py   ->  13 passed
  Both added eviction tests fail on the pre-fix accessor (verified by revert):
  the working set drops from 4/4 to 0/4 resident after one scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(changelog): stamp #6084 content-search working-set preservation

---------

Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-20 01:21:18 +00:00
Frank Song 5c34dd9248 fix(streaming): skip reasoning rows for tool anchors 2026-07-18 04:36:00 +08:00
Frank Song e0dee876a0 fix(streaming): keep recovered tool call anchors 2026-07-18 03:23:45 +08:00
Frank Song 3330bb4bd1 fix(streaming): preserve recovered context shape 2026-07-18 03:23:45 +08:00
Frank Song 59b6022dd2 fix(streaming): harden recovered reasoning context ownership 2026-07-18 03:23:45 +08:00
Frank Song b741e17275 fix(streaming): recover interrupted reasoning 2026-07-18 03:23:45 +08:00
Hermes Agent 1bcf8f8b46 perf(sessions): cache reconciliation message keys 2026-07-17 02:11:31 -06:00
nesquena-hermes 13efcbe06c Merge remote-tracking branch 'origin/master' into p6153b 2026-07-17 01:10:27 +00:00
Ruby Hartono dec041997f fix(models): add defensive row backstop to display-path state.db message read
Memory: get_state_db_session_messages had no SQL LIMIT — it full-scanned the
state.db messages table and fetchall()'d into a Python list. The display path
(GET /api/session) already has a careful since_timestamp optimization that
bounds the common uncompressed tail load, but it deliberately bails (returns
None) on compressed sessions (truncation_watermark/boundary) and msg_before
paging — in those cases the read used to full-scan with no cap. A
pathological/huge state.db could then materialize unbounded rows into memory.

Add an opt-in `limit` parameter to get_state_db_session_messages (applied as a
SQL LIMIT that keeps the NEWEST rows via a DESC subquery re-sorted ASC). It is
a BACKSTOP, not a semantic window: the display window counts visible rows
post-reconciliation, so a true window LIMIT here would corrupt the
sidecar/state.db merge (see _state_db_since_timestamp_for_limited_display,
which deliberately does NOT SQL-LIMIT raw rows for exactly that reason).

Thread a generous _STATE_DB_DISPLAY_ROW_BACKSTOP (50000) from the GET /api/session
display caller only. The full-history model-context callers (reconciliation at
models.py:3437, context reconstruction at :8576, new-turn context at
streaming.py:8443) do NOT pass a limit — their semantics require the full
history, unchanged.

Test: tests/test_state_db_read_backstop.py — no-limit returns full history
oldest-first (model-context contract unchanged); limit=N keeps the newest N
re-sorted oldest-first; limit > row count returns all; limit<=0 clamps to 1
(newest); invalid limit falls back to unbounded; limit composes with
since_timestamp. Existing state.db active-filter + reconciliation suites (54
tests) unchanged.

Contract Routing
- Task type: memory-bound fix (unbounded state.db row materialization on the
  display path's bail cases).
- Touched areas: state.db message reader (api/models.py) + GET /api/session
  display caller (api/routes.py). NOT the model-context callers.
- Relevant public docs: AGENTS.md, CONTRIBUTING.md, docs/CONTRACTS.md,
  docs/rfcs/webui-run-state-consistency-contract.md (Model context layer).
- State layer mutated: none (read-only). The model-context read path is
  UNCHANGED (no limit passed) — Invariant 1 (visible current turns enter model
  context) and the reconciliation contract are preserved. Only the display path
  gets the defensive cap, which does not affect what the agent receives.
- Scope boundaries: this is narrower than a naive "add LIMIT everywhere" —
  _state_db_since_timestamp_for_limited_display's docstring explains why a
  semantic window LIMIT would corrupt the merge; this cap is purely a
  pathological-input safety net above any legitimate session size.

Release note: GET /api/session's state.db read now has a defensive row backstop,
capping memory when the bounded since_timestamp optimization bails on
compressed/paged sessions.

Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
2026-07-16 23:40:26 +07:00
Ruby Hartono 015171eba3 fix(models): cap _CLI_SESSIONS_CACHE with LRU eviction (drop-oldest)
Memory: the CLI/cron session projection cache was a plain dict with TTL storage
but NO size cap and only lazy, same-key eviction. Each value is a copy.deepcopy()
of the full CLI/cron session list (the expensive projection behind #4842/#4672).
The cache key folds in a state.db content fingerprint that advances on every
streamed message, so the next poll builds a NEW key; the previous key's heavy
deepcopy was never looked up again, so its lazy-expiry path never ran. A
long-lived process under churn could accumulate orphaned heavy deepcopies until
the next structural clear_cli_sessions_cache().

Make it an OrderedDict capped at _CLI_SESSIONS_CACHE_MAX_ENTRIES (8) with
drop-oldest on write (while len > MAX: popitem(last=False)) and move_to_end on
fresh hits, mirroring the _CLAUDE_CODE_PARSE_CACHE / _SIDECAR_METADATA_CACHE LRU
pattern in the same file. TTL remains the primary freshness control; the cap is
the backstop the plain dict lacked. clear_cli_sessions_cache() keeps .clear().

Test: tests/test_cli_sessions_cache_cap.py — cache stays at len == MAX after
MAX+5 distinct keys (oldest 5 evicted), a fresh hit marks the entry
most-recently-used so it survives over colder entries, and the cache is an
OrderedDict (the regression: it was an unbounded plain dict). Existing
streaming-cache-TTL/cron-session tests unchanged.

Contract Routing
- Task type: memory-bound fix (unbounded projection cache).
- Touched areas: CLI/cron sidebar projection cache (api/models.py
  _CLI_SESSIONS_CACHE).
- Relevant public docs: AGENTS.md, CONTRIBUTING.md, docs/CONTRACTS.md,
  docs/rfcs/webui-run-state-consistency-contract.md (Sidebar/session metadata
  layer).
- State layer mutated: Sidebar/session metadata layer — specifically the
  in-memory projection cache that backs the CLI/cron sidebar rows. The cache is
  a read-derivation of state.db content; it is NOT a source of truth. Dropped
  entries are rebuilt on the next poll (TTL/invalidation already drive rebuilds).
- Invariant preserved — Invariant 4 (Maintenance is not activity): the cache is a
  derived projection; evicting an entry cannot move a session in the sidebar or
  change a row's content, because the next poll recomputes the projection from
  state.db. TTL remains the primary freshness control; the LRU cap only bounds
  how many STALE orphaned deepcopies linger between invalidations. Regression:
  test_cli_sessions_cache_cap.py plus the existing streaming-cache-TTL and
  cron-session tests (which exercise the real invalidate/rebuild path).
- Scope boundaries: does not change sidebar ordering, unread state, updated_at,
  or the projection result. The cap is a memory backstop; correctness still
  flows from the existing TTL + clear-on-mutation invalidation.

Release note: the CLI/cron sidebar projection cache is now LRU-bounded, capping
worst-case memory for long-lived processes under heavy streaming churn.

Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent). No AI-specific tool
use mattered beyond the repo's standard read/edit/test workflow.
2026-07-16 19:31:47 +07:00
nesquena-hermes b877c9aa40 Merge commit 'refs/pull/6102/head' of github.com:nesquena/hermes-webui into stage/6102-cachettl 2026-07-15 22:40:21 +00:00
starship-s f5ef3688c6 fix(cache): keep streaming TTLs above poll cadence 2026-07-15 20:59:59 +00:00
nesquena-hermes b96405ef29 Merge commit 'refs/pull/6056/head' of github.com:nesquena/hermes-webui into stage/6056-delegtitles 2026-07-15 20:46:34 +00:00
nesquena-hermes 30059085c2 fix(#6083): only grace-evict CONFIRMED never-persisted shells (Codex finding #2)
Codex re-gate asked to distinguish a confirmed-absent sidecar from an
indeterminate/unreadable one before entering the abandoned-shell grace path.
Add _session_sidecar_exists() (True/False/None) and only grace-evict when the
sidecar is confirmed absent (False); a present-or-indeterminate sidecar keeps
the session resident. Age-window bound retained (fully closes the leak, no
data-loss edge); an SSE-mounted-lease refinement is noted as optional follow-up.
2026-07-15 19:06:10 +00:00
nesquena-hermes 02e5acb890 fix(#6083 follow-up): bound never-persisted empty session shells
Codex gate found the eviction-reorder made never-saved empty shells immortal
(abandoned /api/session/new tabs accumulate unbounded past sessions_cache_max).
Add a grace-window bound: protect a never-persisted empty shell only while fresh
(<30min old) OR while it carries a composer draft; a stale, empty, draftless,
never-saved shell is treated as abandoned and becomes evictable again. A real
compose persists a draft on the first keystroke (disk-backed + reloadable), so
the brick fix is preserved while the leak is closed. +2 regression tests.
2026-07-15 18:44:21 +00:00
ai-ag2026andClaude Opus 4.8 7707ea1d2a fix(webui): stop password managers autofilling the search box, and keep unsaved sessions startable
Two independent defects that chain into "new conversations cannot be started":
every POST /api/session/draft and /api/chat/start 404s a few seconds after
POST /api/session/new returned 200.

1. Search inputs are parsed as username fields.

index.html contains no <form> at all, but does contain password inputs (the
Settings "Access Password" panel). Chromium therefore groups the document's
unowned fields into one synthetic password form and picks the first text input
in the DOM — #sessionSearch, the sidebar conversation filter — as that form's
username field, then autofills the saved account name into it on load.
autocomplete="off" was already present; it is ignored for credential
heuristics.

The autofill fires oninput -> filterSessions() -> GET /api/sessions/search
?content=1&depth=5: a full content search the user never asked for, repeated
on every page load.

Fix: give the four search inputs type="search" plus the per-manager opt-outs
(data-1p-ignore / data-lpignore / data-bwignore / data-form-type), and give the
Settings password inputs a real <form> owner with proper autocomplete tokens so
Chromium scopes the credential form to them instead of to the whole document.
The form uses display:contents, so layout is unchanged. CSS suppresses WebKit's
native search clear button, since these fields ship their own
(#sessionSearchClear).

2. A never-persisted session is treated as evictable.

new_session() intentionally keeps a session in RAM until its first message
(#1171), so the SESSIONS cache is its only copy. _session_is_evictable()
(#4765) short-circuited on zero messages, reasoning that an empty shell "is
recreated on next access". It is not: get_session() has no recreate path and
raises KeyError, so both routes 404 and the session can never be started.

The content search from (1) pulls every hit through get_session(), which blows
past sessions_cache_max (default 300) and evicts the session being composed.
That is why this reproduces within seconds on an install with ~1700 sessions,
and why it grew worse as the session count rose.

Fix: require proof of persistence before evicting, for empty sessions too —
which is what the function's own docstring already promised ("Its full state is
already persisted to the JSON sidecar"). The zero-message branch was the single
path that bypassed it. #4765's memory bound is unaffected: sessions loaded from
disk remain evictable, and its existing tests still pass.

Trade-off: an unsaved empty session now stays resident for the process
lifetime (one per "New Conversation" click, an empty object each). That is a
deliberate application of the function's stated invariant — "slightly more RAM
for a session we are unsure about is strictly better than evicting an unsaved
session". Bounding it by age instead would also work if maintainers prefer.

Validation:
  pytest tests/test_issue4765_sessions_lru_eviction.py  ->  9 passed
  The added regression test fails on the unpatched predicate (verified by
  reverting api/models.py alone) and passes with the fix.

Note: (1) fixes what triggers the churn, (2) fixes what turns that churn into
an unstartable session. Either fix alone leaves a real defect in place, so
they are submitted together.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 10:06:41 +02:00
pxxD1998 73b6c5d604 fix(sessions): cascade delegated subagents on parent deletion 2026-07-14 18:10:03 +08:00
Rod Boev ab48096de1 feat(#6033): show delegated goal titles for child sessions 2026-07-14 04:01:06 -04:00
Isla-Liu 2e8915baf8 perf(webui): skip impossible state-db prefix normalization 2026-07-14 05:38:34 +00:00
Rod Boev 064ad82ee6 fix(#5940): key pending-turn recovery to exact checkpoints 2026-07-13 03:41:39 +00:00
nesquena-hermes 53a4b35555 harden #5979 round 3 (Codex): signature-based explicit-pick provenance
Codex found a verified lifecycle hole: the bare boolean model_explicitly_picked
was not cleared on /api/session/update (api/routes.py:14438-14460 changes+persists
the model/provider without touching it), so after an update+reload the flag stayed
True and a cold #433 stale leftover (openai/gpt-5.4) was wrongly preserved instead
of stripped to gpt-5.4.

Fix (Codex's prescription): replace the unscoped boolean with a SIGNATURE bound to
the routing context.
- api/models.py: new model_explicit_pick_signature(model, provider) ->
  '<model>\x1f<provider>'; Session.model_explicit_pick_signature persisted field
  (replaces the boolean; in METADATA_FIELDS, restored via kwargs, survives restart).
- api/routes.py /api/chat/start: on a fresh explicit_model_pick, stamp the signature
  of the resolved model+provider.
- api/streaming.py: treat the send as a deliberate pick ONLY when the persisted
  signature equals the signature recomputed from the session's CURRENT persisted
  model+provider. Any model/provider change (chat-start, session-update,
  normalization, provider repair) yields a different signature and auto-invalidates
  the stale pick — no per-mutation-site clearing needed.

So: deliberate pick on unchanged context -> preserve (b3nw); any switch away ->
signature mismatch -> unmarked -> legacy strip (#433 stays correct even after a
session-update+cold-restart). Adds signature match/invalidate/persist tests.
75 focused tests pass (resolver + sprint40 + the 2 streaming-mock suites).
2026-07-13 01:58:20 +00:00
nesquena-hermes 45cb5bd3e6 feat(#5979): persisted explicit-pick provenance for cold custom-proxy resolution
Nathan's call: adopt Codex's complete mechanism instead of the unconditional
cold-preserve. The cold-catalog custom-proxy decision is now gated on whether the
user DELIBERATELY selected the model this session, closing the residual where a
stale #433 leftover would preserve+400 on a cold+no-disk send.

Mechanism:
- resolve_model_provider(model_id, *, explicitly_picked=False): in the custom-arm
  cold branch (no warm provenance, not config-declared), explicitly_picked=True
  preserves the vendor namespace verbatim (deliberate pick, proxy routes on it);
  otherwise the legacy redundant-prefix strip runs (stale cross-provider leftover,
  #433). Warm endpoint-advertised provenance still wins over the flag.
- Session.model_explicitly_picked: new persisted field (default False, restored
  from metadata via **kwargs, listed in METADATA_FIELDS so it survives a restart
  — b3nw's exact cold scenario).
- /api/chat/start stamps it: True on a fresh explicit_model_pick; cleared only
  when the resolved model actually CHANGED without a pick (real switch away);
  preserved across plain follow-up sends of the same model (the onchange marker
  is one-shot, so per-send explicit_model_pick alone would drop the intent).
- streaming.py reads s.model_explicitly_picked and passes explicitly_picked= into
  resolve_model_provider (inside the profile scope).

Independent end-to-end matrix (all pass): ben default cold, ben picked non-default
cold+warm, ben stale cold (strips), #433 warm+cold, #3872 bedrock, #548 zai-org.
67 resolver+sprint40 tests pass incl. new explicit-pick + stale-companion +
persistence round-trip.
2026-07-13 01:43:17 +00:00
b2ae71e01a Release exp-v0.52.48: post-compression context metering (#5872, #4685) (#5977)
* fix(#4685): meter post-compression model context

* Release exp-v0.52.48: meter post-compression model context (#5872, #4685)

---------

Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-07-11 20:41:43 -07:00
nesquena-hermesandMicoRobot. 75153f5b79 fix(share): always-on redaction boundary + rebase/UX fixes (gate findings)
Adversarial security re-gate on the rebased head found 4 issues; fixed:

1. [CORE] api/shares.py public snapshot could leak credentials/paths/tool
   payloads. The sanitizer relied on redact_session_data (gated on the
   user-toggleable api_redact_enabled) — disabling it exposed API keys, a dict
   assistant content was stringified as raw tool data, and session.workspace
   survived in transcript text. Now the per-message sanitizer applies ALWAYS-ON
   credential redaction (_redact_fn_cached, force=True, ignores the setting) +
   scrubs known session/workspace/home paths, and refuses to stringify non-text
   structured content. Title is force-redacted too. Regression test proves the
   boundary holds with api_redact_enabled=False.
2. [SILENT] api/models.py: my rebase wrongly kept anchor_activity_scenes in
   METADATA_FIELDS, moving 250-480KB scene bodies before messages and undoing
   master's metadata fast-path (#5854). Removed; 17 #5854 tests pass again.
3. [SILENT] static/index.html: the Share/StopSharing/ExportHTML button triplet
   was double-applied by the rebase (duplicate IDs → only the first wired).
   Deduped to one each + a count assertion test.
4. tests: fixed the stale Session.load-after-/api/session/new setup (master
   keeps new sessions memory-only until first message).

Also (UX, found in the visual flow sweep): a clipboard-copy failure after a
SUCCESSFUL share showed a red 'Share failed' toast and skipped opening the page.
The copy is now isolated — the share succeeds, the page opens, and the toast
shows the link if the clipboard write fails, never a false failure.

Co-authored-by: MicoRobot.
2026-07-11 02:06:34 +00:00
nesquena-hermes e061e8534c Rebase #5337 (public read-only conversation sharing) onto master
Rebased 363 commits forward (branch-point 2026-07-05). Applied the PR's code
diff cleanly across 16 files; resolved api/models.py additively (both master's
process_wakeup_pause/anchor_scene_index fields AND the PR's share_token/
share_created_at fields kept in the Session constructor, metadata list, and
to_dict). CI workflow (.github/workflows/tests.yml) intentionally NOT taken from
the stale branch — master owns it (the branch's apparent removal of Playwright/
Office steps was a PAT-workflow-scope revert; rebase restores master's CI).

Co-authored-by: MicoRobot. Concept approved by maintainer (opt-in public share,
2026-07-11). Note: 4 dynamic share tests fail identically on the original
unrebased head (pre-existing test-env Session.load fragility, not rebase-caused);
to be resolved during the security re-gate in a proper sandbox.
2026-07-11 01:39:42 +00:00
Frank Song 63dda7fe2d Merge remote-tracking branch 'origin/master' into paperclip/pr-5732
# Conflicts:
#	api/models.py
2026-07-10 10:15:21 +08:00
e4342a65cf Release: split anchor_activity_scenes after messages to fix #4633 server memory growth (#5858, #5854) (#5863)
Reasoning-heavy sessions accumulate 250-480KB of anchor_activity_scenes serialized
BEFORE the messages array, so the 64KB metadata-prefix read always overflowed ->
full-parse fallback on every read -> memory growth. Move scene data AFTER messages;
metadata prefix now ~2KB, modern reads skip the full parse. Old-layout files still
load (one authoritative parse then cached); round-trip preserves scenes exactly.

Independent review: nesquena APPROVED (end-to-end, on head). Gate-pass: Codex SAFE
(933KB legacy sidecar loads clean + round-trip verified), 17 own + 2945 regression green.

Co-authored-by: t <a@b>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-07-09 19:05:54 -07:00
Frank Song 3a0c00c880 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-10 00:22:56 +08:00
91606a15e0 Release Batch B: OPTIONS preflight framing + session-index bytes-parse (#5828, #5834) (#5838)
Phase 1 low-risk backend batch (on top of Batch A). Both byte-verified vs PR head,
Codex SAFE, 64 own + 749 regression tests pass, coexists with Batch A's #5833.

- #5828 (ai-ag2026): frame CORS OPTIONS preflight with Content-Length: 0 (keep-alive hang fix)
- #5834 (ai-ag2026): parse session _index.json from bytes not decoded str (all 10 sites)

Co-authored-by: t <a@b>
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-07-09 09:19:14 -07:00
Frank Song 61d32671ed Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-10 00:07:14 +08:00
0d3ee7de9f Release Batch A: worktree cli-shadow + atomic settings.json + state.db read-only (#5832, #5829, #5833) (#5837)
Phase 1 low-risk backend reliability batch. Three independent contributor fixes
on distinct files, each byte-identical to its PR head, Codex SAFE, 691+16 tests pass.

- #5832 (silent-reader-cn): importlib-load agent cli.py by absolute path so a
  shadowing cli/ namespace package can't brick worktree creation
- #5829 (ai-ag2026): atomic settings.json writes (temp+fsync+os.replace, mode-preserving)
- #5833 (ai-ag2026): open the remaining 8 state.db read projections read-only

Co-authored-by: t <a@b>
Co-authored-by: silent-reader-cn <silent-reader-cn@users.noreply.github.com>
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-07-09 09:04:59 -07:00
Frank Song bdb66d8ed5 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-09 14:13:47 +08:00
Konstantin M 12a60a88b4 perf(webui/session-load-latency) tier3e: fix empty-scene inconclusive prefix read (Greptile P1)
When cached session has no anchor_activity_scenes and the prefix reader
returns None (e.g. sidecar too large for prefix), the empty-scenes path
did not mark _scene_check_inconclusive = True. This meant an inactive
session with matching message counts would return False (cache fresh)
even though disk may have gained its first scene record.

Fix: the empty-scenes else-branch now sets _scene_check_inconclusive
when disk_meta_quick is None, matching the existing behavior in the
cached-scenes branch (line 3173). The code then falls through to the
full metadata comparison instead of serving a stale cache.

discussion_r3548650345
2026-07-09 03:44:55 +00:00
Konstantin M db99f72505 perf(webui/session-load-latency) tier3d: Greptile P1 followups — inconclusive scene check + full-content config verification
Two Greptile P1 comments from review of b5c0447a (tier3c):

1. Inconclusive Prefix Stays Fresh (discussion_r3548477859):
   When _persisted_session_meta_prefix() returns None and cached has scene
   records, the fast-path skipped scene comparison entirely and returned
   False (cache fresh) for inactive equal-count sessions. Added
   _scene_check_inconclusive flag that forces fall-through to the full
   Session.load_metadata_only() comparison when the cheap prefix read
   fails, preventing stale anchor_activity_scenes.

2. Prefix Check Misses Edits (discussion_r3548477915):
   The 128-char content-prefix backstop in _read_profile_config_cached
   only detected edits within the first 128 bytes of config.yaml. Replaced
   with a full-content comparison that reads the entire file (~1-10KB)
   and compares it to a copy stored in the cache entry. This detects
   edits anywhere in the file at a fraction of the cost of yaml.safe_load().
   Updated cache type hint from tuple[float, str|None, dict] to
   tuple[float, str, dict].

Tests: 108 passed (41 reconciliation + 39 anchor-scene + 28 profile-config)
2026-07-09 03:08:11 +00:00
Konstantin M b5c0447a4a perf(webui/session-load-latency) tier3c: fix scene comparison direction in cache fast-path
The fast-path scene comparison in _cached_session_lags_disk had two bugs
flagged by nesquena-hermes review:

1. Direction: used symmetric != instead of directional subset check.
   When the cache had scene keys that disk lacked (cache-ahead), the old
   code force-reloaded from disk, silently dropping un-persisted scene
   data. Now mirrors master: reload only when disk is strictly ahead
   (disk_keys.issubset(cached_keys)). The 'not disk_scenes' early-return
   is also removed — empty disk scenes means disk is not ahead, so keep
   the cache.

2. Key filtering mismatch: used raw set(disk_scenes.keys()) instead of
   _anchor_scene_record_keys() helper which filters to truthy keys with
   dict values. Now filters disk keys the same way as master, and uses
   _anchor_scene_record_keys(cached) for the cached session object.

Also updated the tier3b content-prefix docstring to clarify it is a
best-effort backstop limited to the first 128 chars, not a full
integrity check.
2026-07-09 02:21:25 +00:00
Konstantin M f8cd40d74a perf(webui/session-load-latency) tier3a: Greptile follow-up fixes (P1 empty-scenes skip, P2 draft-log sink, P2 config-cache inode)
Three unaddressed Greptile review comments fixed:

P1 - Empty scenes skip check (api/models.py):
When the cached session had no anchor_activity_scenes, the scene-comparison
branch was skipped entirely by the 'if cached_scenes:' guard. An inactive
session would return False (cache fresh) even when disk had gained its first
scene record. Add an else clause that checks disk for scene records when the
cache has none.

P2 - Draft slow log sink (api/routes.py):
The /api/session/draft handler's slow-path log (>200ms) used logger.warning,
which is silently dropped in the WebUI process (root logger has no handler).
Route through handler._safe_webui_print() instead, the same fix applied to
the /api/session slow log in tier2c.

P2 - Config cache same-timestamp stale (api/routes.py):
The profile config.yaml cache key (profile_name, mtime, size) could collide
on coarse-mtime filesystems (FAT, some network FS) when a config edit within
the same timestamp tick happened to produce the same byte count. Add st_ino
(inode) to the cache key — most editors use atomic-rename writes which assign
a new inode, so the key changes even when mtime+size are identical.
2026-07-09 00:28:26 +00:00
Konstantin M fabcd5239b perf(webui/session-load-latency) tier1a-followup: fix anchor-scene staleness in cache fast-path
Greptile review caught that the Tier 1a fast-path returns 'cache fresh' when
disk_count == cached_count AND the session is inactive, but api/routes.py
saves the sidecar after editing only anchor_activity_scenes (no message
change). That path leaves the cached scene records stale until the cache is
otherwise invalidated.

Fix: when the cached session has scene records, also compare them against
the disk's scenes via the same metadata-prefix read that _persisted_message_count
already does. anchor_activity_scenes is in METADATA_FIELDS and serializes
before messages, so it lands in the prefix.

New _persisted_session_meta_prefix() helper exposes the parsed prefix dict;
_cached_session_lags_disk reads it and compares scene key sets + max
updated_at against the cached session.

Cost: one extra dict parse per cache hit on sessions that have scene
records, no cost on sessions without. Inactive sessions still take the
fast-path exit when scenes match.
2026-07-08 23:43:57 +00:00
Konstantin M ec31891c60 perf(webui/session-load-latency) tier1a: cheap-first freshness check in _cached_session_lags_disk
Prior to this change _cached_session_lags_disk() called
Session.load_metadata_only(sid) on every LRU cache hit, parsing the
full sidecar JSON (~15-20ms even for a 1.3MB sidecar on Celeron+ eMMC).
For draft auto-saves hitting get_session() on every keystroke debounce
(~400ms cadence while typing), that 15-20ms multiplied out to ~75% of
the request's wall time on the Chromebook.

Reorder the check: read only the JSON metadata prefix
(_persisted_message_count, already defined for the LRU-eviction path)
to compare message counts. For INACTIVE sessions
(no active_stream_id, no pending_user_message) with matching counts,
the cache is provably at parity with disk — anchor-scene records cannot
have advanced without the message count advancing too, and the full
Session.load_metadata_only() is skipped. For ACTIVE sessions, or when
counts differ, fall through to the original anchor-scene comparison
path so correctness is preserved.

Measured cost reduction on the request hot path: 15-20ms -> <1ms on
the typical cache-hit case (inactive session, count matches). The
slow path is unchanged for cases that genuinely need the full reload.

No public API change. No behavior change observable on user-facing
flows. The LRU eviction safety invariant (#4765) is preserved —
_session_is_evictable() still calls _persisted_message_count under
the same conditions, and Tier 1a adds NO new eviction gate.
2026-07-08 22:39:08 +00:00
Frank Song 76478729b8 fix(wakeup): record pauses from turn route lane 2026-07-08 01:57:46 +08:00
Frank Song 5764df66f3 fix(wakeup): share process pause lane resolution 2026-07-07 17:00:36 +08:00
Frank Song 1ab9c47e9b fix(wakeup): canonicalize paused route model lanes 2026-07-07 15:09:30 +08:00
Frank Song 09862a3f9a fix(wakeup): stabilize credential pause revalidation 2026-07-07 14:08:25 +08:00
Frank Song b25e99c1b4 fix(wakeup): revalidate credential pause on refresh 2026-07-07 12:34:41 +08:00