Commit graph

2366 commits

Author SHA1 Message Date
Frank Song
4bbce7bc45 Persist restored wakeup pause after streaming cancel 2026-07-10 05:43:44 +08:00
Frank Song
d9e45f1d07 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-10 05:27:46 +08:00
nesquena-hermes
a0067b0b24
Release: classify external_agent imports as CLI to match client render (#5846, #5831) (#5855)
Fixes the #5831 brick: "WebUI sessions (N)" chip showed a count but an empty list.
Server is_cli_session_row classified read-only external_agent imports (Claude Code,
Codex) with a real title as non-CLI (counted under webui_session_count), while the
client _isCliSession filed them in the CLI bucket -> mismatch. Server now classifies
external_agent/external-agent as CLI, matching the client. WebUI/messaging/subagent
unaffected (their exclusions precede the new branch); external_agent imports stay read-only.

Self-built (nesquena-hermes); independent nesquena review APPROVED on this head (17:28);
gate-pass (Codex SAFE, no over-capture, caller audit clean), 14 own + 180 regression green.

Co-authored-by: t <a@b>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-07-09 14:20:20 -07:00
nesquena-hermes
f449ff21c6
Release: hard-prune post-compression tool context (#5667) (#5852)
WebUI hard token-cap on protected-tail tool-result payloads in the MODEL CONTEXT
only, applied once after a confirmed compression event, to prevent post-compression
context blowup. Deep-copy content-replace only (no message removal/reorder → no
orphaned tool_result); visible transcript (s.messages) untouched.

Deep-review re-gate: Codex SAFE (pairing-safety + context-only + idempotent verified
by reproduction), 65 own + 992 regression tests green. Crown-jewel streaming path.

Co-authored-by: t <a@b>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-07-09 13:41:31 -07:00
Frank Song
4efab593fe Preserve wakeup pause on cancelled gateway recovery 2026-07-10 04:03:40 +08:00
Frank Song
4af5334ebb Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-10 03:33:07 +08:00
nesquena-hermes
2b457f1646
Release: cap StreamChannel offline replay buffer (#5788) (#5849)
Bounded deque(maxlen) drop-oldest so a disconnect-without-cancel can't grow the
per-turn offline replay buffer unbounded (per-abandoned-turn OOM risk, #4633 family).
Dropped frames recovered via run-journal last_event_id replay path.

Deep-review gate: Codex SAFE (journal-recovery verified end-to-end), 20 own + 1106
regression tests green. Crown-jewel streaming path.

Co-authored-by: t <a@b>
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-07-09 12:26:05 -07:00
Frank Song
2547ec98d1 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-10 02:02:04 +08:00
nesquena-hermes
d6e5dc302d
Release Batch D: terminal resource-leak fix + fsync atomic writers (#5835, #5843) (#5848)
Phase 2 backend reliability/data-integrity batch. Each byte-verified vs PR head,
Codex SAFE, 20 own + 355 regression tests pass, disjoint files.

- #5835 (ai-ag2026): stop leaking fd + reader thread + shell on client/shell exit (#4633 family)
- #5843 (ai-ag2026): fsync atomic JSON writers (oauth/passkeys/discoverability) + thread-safe temp

Co-authored-by: t <a@b>
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-07-09 10:54:06 -07:00
Frank Song
fe656a48ce Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-10 01:42:11 +08:00
nesquena-hermes
7a1a122e6e
Release Batch C: hidden-tab dashboard poll + SSE write-deadline + rate-limit map bound (#5840, #5841, #5842) (#5847)
Phase 2 low-risk backend/perf batch (ai-ag2026 reliability cluster). Each byte-verified
vs PR head, Codex SAFE, 33 own + 720 regression tests pass, disjoint routes.py regions.

- #5840: skip dashboard status poll while tab hidden (perf; forced polls unaffected)
- #5841: arm write deadline on session-events invalidation SSE (stuck-client teardown)
- #5842: bound client rate-limit maps by sweeping stale keys (long-uptime memory)

Co-authored-by: t <a@b>
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-07-09 10:38:47 -07:00
Frank Song
a9ba8d8368 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-10 01:07:49 +08:00
nesquena-hermes
1e275d04ed
Release: queue gateway-owned steer fallback (#5823) (#5845)
Steering a live gateway-owned run had no process-local agent in SESSION_AGENT_CACHE,
so it errored with "No agent cached" and dropped the typed text. Now detects the live
gateway ACTIVE_RUNS stream and queues the steer as the next turn (model/provider/profile
snapshotted first), updates the queue badge, clears the draft, shows a toast — without
cancelling the stream. Gateway sibling of the shipped #5597 fallback.

Codex SAFE + Fable SHIP-UX; byte-identical to PR head; 66 steer/busy-input tests pass;
Nathan visual-approved (desktop + mobile screenshots).

Co-authored-by: t <a@b>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-07-09 10:04:21 -07:00
Frank Song
3a0c00c880 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-10 00:22:56 +08:00
nesquena-hermes
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
nesquena-hermes
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
de9d3306af Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-09 16:22:06 +08:00
nesquena-hermes
dfd9b57183 fix(gateway): keep 600s read-timeout default (gate finding — 120s would kill legit long silent tool calls)
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-07-09 08:03:02 +00:00
ai-ag2026
fcb135a133 fix(streaming): bound gateway SSE read-inactivity, honor Stop terminally (#2476)
Both gateway SSE read loops used urlopen(timeout=600) and re-checked cancel only
BETWEEN lines, so a half-open gateway (TCP open, zero bytes) pinned the worker
for the full 10 minutes and ignored Stop.

Route raw-line iteration through _iter_sse_lines_cancellable and bound the socket
to a byte-silence budget (_gateway_read_timeout_secs, default 120s, env
HERMES_WEBUI_GATEWAY_READ_TIMEOUT). A read timeout is TERMINAL — CPython's
socket.makefile latches _timeout_occurred on the first socket.timeout, after
which every read raises a bare OSError, so the connection can't be resumed. On a
read timeout (or that poisoned-socket OSError) the generator surfaces the user's
Stop (yield b"" -> caller emits cancel) or re-raises so the caller reports the
stall. A stream that keeps emitting bytes within the window never trips it, so a
genuinely alive (if slow) turn is untouched; only >budget of total byte-silence
is treated as a dead gateway. Both loops (runs-API + legacy) changed identically.

The test fake now models the timeout-poison latch (a bare OSError after the first
timeout) so it reflects real socket semantics; the previous stateless re-raise
had made a multi-window "recovery" path look green that cannot occur in
production.

Refs #2476
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 08:03:02 +00:00
Frank Song
96af63a95e Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-09 14:41:48 +08:00
Frank Song
bdb66d8ed5 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-09 14:13:47 +08:00
g
9a1fdabfda Merge remote-tracking branch 'origin/master' into pr-5557 2026-07-09 06:12:41 +00:00
Frank Song
0cef502329 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-09 14:02:13 +08:00
g
9da97c3c78 Merge remote-tracking branch 'origin/master' into pr-5803 2026-07-09 05:56:20 +00:00
ai-ag2026
c88d89903a Document intentional lock-free GIL-atomic contract for STREAM_* buffers
The per-token hot path mirrors partial text, reasoning, and live tool
calls into STREAM_PARTIAL_TEXT / STREAM_REASONING_TEXT /
STREAM_LIVE_TOOL_CALLS without holding STREAMS_LOCK, while snapshot
readers (_snapshot_and_append_partial_on_error, cancel_stream) read them
under STREAMS_LOCK. This is safe under the CPython GIL for two reasons —
NOT because `+=` is one bytecode (it is not: `d[k] += s` compiles to
load / add / STORE_SUBSCR):

  1. Single writer — only the streaming thread ever writes a given
     stream_id's buffers, so there is no writer/writer race to tear.
  2. Reader/writer atomicity — `+=` builds a complete new immutable str,
     then the final STORE_SUBSCR binding it into the dict is a single
     atomic bytecode, so a concurrent reader's dict.get returns either
     the old or new *complete* string (strings are immutable), never a
     half-built one. list.append / single-key dict writes are atomic too.

So a reader sees a complete-but-possibly-stale value, never a torn one,
and the snapshot is a best-effort partial reconciled by later journal/SSE
events.

Chose documentation over locking: pulling the writes under STREAMS_LOCK
would add per-token contention against readers copying large buffers and
entangle the documented LOCK -> STREAMS_LOCK ordering (deadlock risk) for
only a recoverable staleness window. Make the assumption explicit at the
write sites and the reader instead. No behaviour change.

Rollback: revert this commit (comments only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 05:50:17 +00:00
Frank Song
590fec43aa fix(gateway): preserve completed webui turns on late cancel 2026-07-09 13:24:48 +08:00
Konstantin M
e96318d2e5 perf(webui/session-load-latency) tier4: fix CI failures — lint B009/B905 + _emit_slow format args
Three fixes for the CI failures (lint + test shard 1):

1. B009 lint fix (api/routes.py:6281): Replace getattr(session, 'profile')
   with session.profile — the guard on line 6275 already ensures it exists.

2. B905 lint fix (api/routes.py:13843): Add strict=True to zip() of
   _draft_stages slices. Both sides are always the same length (shifted
   by one), so strict=True is correct.

3. _emit_slow format-arg fix (api/request_diagnostics.py:186-195):
   Keep the prefix in the format string and pass the JSON payload as a
   separate positional argument. The test at
   test_issue1855_request_diagnostics.py:33 does
   json.loads(caplog.records[0].args[0]) and the old code embedded the
   prefix in the payload string, breaking the JSON parse. Now
   self.logger.warning("%s %s", prefix, payload) — wait, actually
   self.logger.warning(f"{prefix} %s", payload) — so args[0] is the
   raw JSON string, same as the pre-_emit_slow code.
2026-07-09 04:22:21 +00:00
Frank Song
5d0dfe4b52 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-09 12:01:56 +08:00
ai-ag2026
b88b922db5 Single-flight remote gateway health probe to stop cold-cache stampede
_probe_remote_gateway held _remote_probe_lock only around the cache
read and cache write; the network probe itself (_http_probe per path,
each up to ~2s) ran unlocked. On a cold cache a dashboard that fans out
to N panels fired N concurrent probe sets at the (possibly dead)
gateway, each blocking up to ~6s (#5455, #2476).

Wrap the existing lock in a Condition and add an in-flight set: the
first 'leader' thread marks the base_url in-flight and runs the probe
(via extracted _run_remote_probe), while latecomers wait on the
Condition for the leader's cached result instead of probing themselves.
The leader clears the marker and notify_all()s in a finally block, so
waiters are woken even if the probe raises (no deadlock); a bounded wait
budget lets a waiter fall back to probing itself if a leader hangs.
_reset_remote_probe_cache_for_tests also clears the in-flight set.

Expire the cache from the probe's COMPLETION time, not the leader's
entry time: walking every path of a hung gateway takes
len(paths) * timeout (~6s), which exceeds the 5s TTL, so
`entry_time + TTL` would store an already-expired cache line. Waiters
woken right after would then miss the cache and each re-probe the dead
gateway — collapsing single-flight and regressing latency to worse-than-
serial. A regression test drives a walk longer than the TTL and asserts
still exactly one probe set (the shipped happy-path test kept the walk
well under the TTL and so never exercised this).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 03:54:10 +00:00
ai-ag2026
0ac2b3a3fa Cache next-seq per run journal path to avoid O(n^2) appends
append_run_event() without an explicit seq called _next_seq(path), which
re-read and re-parsed the entire journal file on every append — O(n) per
append, O(n^2) over a run. Direct callers therefore paid a full file
reparse per event; only RunJournalWriter avoided it via its own in-memory
counter.

Introduce a module-level _SEQ_CACHE keyed by run-journal file path. The
per-path _lock_for(path) serializes same-path reserve->append so seqs
stay monotonic and file order matches; both RunJournalWriter and the free
append_run_event now draw from this one cache (_reserve_next_seq), so
seqs stay gapless even when both write the same path — the writer's
private counter is removed to keep a single source of truth. An explicit
caller-supplied seq advances the cache (_note_assigned_seq) so it can
never be re-issued. delete_run_journal() now also evicts cache entries
for the removed session so a run re-created at the same path restarts
numbering at 1.

Guard every structural _SEQ_CACHE access (reserve/note/evict) with a
dedicated _SEQ_CACHE_LOCK. The eviction sweep in delete_run_journal
iterates the whole dict; the per-path locks the append path holds do NOT
serialize against it, so a concurrent append on another session inserted
a fresh key mid-iteration and raised "dictionary changed size during
iteration" (the caller swallows it, but the eviction then aborts and
leaks stale seqs, so a re-created run resumed a stale number instead of
restarting at 1). A stress test races deletes against cross-session
appends and asserts no such error.

Rollback: revert this commit; _next_seq(path) re-read behaviour returns.
No on-disk format change, so existing journals stay compatible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 03:49:09 +00: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
Frank Song
24053bf025 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-09 11:23:11 +08:00
Frank Song
6ca459708e fix(wakeup): keep unresolved provider pauses 2026-07-09 11:22:54 +08:00
Hermes Agent
69d0fc73c9 fix: derive service tier support from agent fast-mode metadata 2026-07-09 03:16:53 +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
Frank Song
153207eaad fix(wakeup): allow distinct config key recovery 2026-07-09 10:24:49 +08: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
Frank Song
33859b2704 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-09 09:43:54 +08:00
Frank Song
d8b7ac23d6 fix(wakeup): require pool recovery before clearing pause 2026-07-09 09:43:48 +08:00
Konstantin M
aa78590164 perf(webui/session-load-latency) tier3b: content-prefix verification in profile config cache
Greptile P1 (discussion_r3548069113): the inode+mtime+size cache key
misses in-place rewrites on coarse filesystems. When a file is rewritten
in-place (same inode, same mtime tick, same byte size), the cache returns
the old parsed config for up to 60s.

Fix: on cache hit, read the first 128 characters of the file and compare
against the stored prefix from when the entry was populated. If they differ,
the content has changed — fall through to re-parse. Cost on hot path: one
128-char read + string comparison (negligible vs YAML parse).
2026-07-09 01:28:56 +00:00
sbe27
68d406f7ff fix(models): refresh OpenCode Go static catalog 2026-07-09 01:23:08 +00:00
ai-ag2026
8dde1de4fa fix(bg): back off drain loop instead of silent tight-spin (#2476, #4633)
_drain_loop read process_registry.completion_queue directly under a bare
`except Exception: continue`. A registry missing that attribute raised
AttributeError that was swallowed and retried with no backoff — a
100%-CPU tight loop with no log line. The loop now reads the queue via
getattr(..., None) and backs off on the stop event when it is absent
(mirroring streaming.py), catches queue.Empty explicitly for the normal
idle path, and logs a warning + backs off (_DRAIN_STOP.wait(1.0)) on any
other queue error so a persistent fault can neither spin the CPU nor stay
invisible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:18:21 +00:00
Frank Song
a5974d0ae9 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-09 09:15:09 +08:00
Frank Song
9d9d52958e fix(wakeup): restore pause on streaming stop race 2026-07-09 09:15:04 +08:00
Frank Song
b21038f015 Merge remote-tracking branch 'origin/master' into paperclip/pr-5732 2026-07-09 09:06:27 +08:00
Frank Song
59c868fe86 fix(wakeup): preserve pause on gateway stop race 2026-07-09 09:06:19 +08:00
ai-ag2026
b10116bffc Frame passkey-login 200 response with Content-Length
The passkey-login success handler wrote its 200 JSON body without a
Content-Length header, unlike the password-login and logout handlers. Under
HTTP/1.1 keep-alive that response is unframed, so the browser's fetch().json()
waits for connection close and appears to hang until it times out.

Encode the body once and send Content-Length before end_headers(), mirroring
the /api/auth/login block. Header order is unchanged so set_auth_cookie's
Set-Cookie still precedes end_headers().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:05:28 +00:00
ai-ag2026
4fc51072a2 fix(streaming): pair metering begin_session with end_session teardown (#4633, #2476)
The streaming worker called meter().begin_session(stream_id) but never a
paired end_session. A turn that produced zero output tokens (pre-flight
cancel, or a setup exception before the first token) left a _SessionMeter
in GlobalMeter._sessions forever: get_stats() only prunes sessions with
first_token_ts > 0, so a zero-token session is never reclaimed, and each
leaked entry inflates the `active` count sent over the SSE metering event.

begin_session() and the metering ticker's .start() are now registered as
the first statements inside the worker's outer `try`, and paired with an
idempotent meter().end_session(stream_id, 0) plus a deterministic
_metering_stop.set() in that try's outer `finally` (the same block that
pops STREAMS/CANCEL_FLAGS) — so every exit path tears the session down.
Deferring .start() until after put() is defined also closes a latent
start-before-put ordering window in the ticker closure.

The metering payload is unchanged; end_session only pops the session.

Verification: tests/test_metering_session_lifecycle.py (leak reproduction,
end_session reclaim, idempotency, begin->cancel-before-token->empty) plus a
plain-assert run on python3.11 (conftest gates pytest to 3.11-3.13; system
python is 3.14). py_compile clean.

WARNING streaming-contract: adjusts the streaming worker's begin/end
teardown ordering. Needs RFC/contract review against docs/rfcs/
(session-sse-contract-v1.md) before merge — draft PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 00:51:28 +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