The row-id fast path for a marked native-image mirror row continued
without advancing state_replay_idx, unlike the ordinary and multimodal-
mirror replay paths. The edited checkpoint then never read as consumed,
so a rerun reply that exists only in state.db was truncated. Master (no
marker branch) keeps that reply.
Advance the replay index when the resolved sidecar row is the next
expected visible row, the same guard the mirror branch uses. Adds a
regression that fails without the fix: sidecar [u1,a1,img#42,a_img,uE],
state [.., marked img#42, a_img, u_old, a_old, uE, aE], watermark 900,
boundary 861 -> aE survives, u_old/a_old stay out.
Found by the release-manager advisor gate on #7754.
Apply the existing truncation and checkpoint-order eligibility to marked
state.db image rows before insertion. Keep removed turns out of full,
limited, and model-context reads without changing no-watermark replay.
Preserve conflicting provider payloads for a uniquely proven retained
image row across Undo without restoring distinct removed rows.
Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
Keep image-turn context tagging and upstream's typed steer-wrapper cleanup
in the same settlement path. Verify the combined recovery and streaming
behavior against current master.
Assisted-by: Hermes Agent:gpt-6-sol
Count a proved WebUI-owned pending row in the display baseline before
accepting a state.db self-heal. Without assistant output past that turn,
leave pending state for the run journal to restore partial text and the
interruption marker instead of clearing it prematurely.
Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
Assisted-by: Claude Code:claude-opus-5-5
When a dead stream is reconciled from state.db, retain the submitted
prompt and attachment in the sidecar while keeping the Agent-enriched
row in model context. Use one parent snapshot for full-session conflict
projection so the sidecar owner is stable across the display merge.
Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
Assisted-by: Claude Code:claude-opus-5-5
Apply the same display-only conflict projection to full and paginated
session reads before cache and window selection. Keep both provider payloads
for model replay while retaining a single visible sidecar bubble.
Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
Record pending-turn proof under caller-owned session locks during normal
runs and credential recovery. Preserve both conflicting provider payloads
for replay, but display one sidecar-owned bubble when row identity and
visible content are unambiguous.
Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
Persist trusted pending Agent timestamp evidence before the model call so
session reads, including metadata polls and cold reloads, keep only the
submitted prompt and attachment visible. Preserve conflicting provider
payloads without guessing which copy is newer.
Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
The suppression predicate _transcript_already_advanced_past_pending()
required a run-journal terminal 'completed' state, but its caller in
_apply_core_sync_or_error_marker already returns early on that same
condition. The predicate branch was therefore unreachable dead code: it
could never suppress anything, and stale-cancel recovery could still
append a duplicate recovered user row, a journal clone and an
interruption marker for a turn that actually finished.
Derive the completion evidence from the crash-safe turn journal instead:
_turn_journal_records_completion() reports True when the exact-stream
'completed' event is the latest terminal state for the pending stream id.
The run journal's terminal write is not durable evidence for this path
(it is consumed by the caller's early return), so the turn journal is the
only still-observable source. A cancelled/crashed turn (interrupted)
keeps recovery active, and completion for another stream of the same
session never settles the pending one.
Also record the same completion event on the Gateway success writeback
path, so Gateway-backed runs have the equivalent completion persistence
instead of leaving the run journal's lost terminal write unrecoverable.
Regression coverage:
- test_turn_journal_helper_reports_completed_for_matching_stream
- test_turn_journal_helper_rejects_interrupted_turn
- test_advances_past_pending_from_turn_journal_without_run_journal_terminal
- test_full_recovery_suppresses_duplicates_on_turn_journal_completion
- test_full_recovery_appends_rows_on_turn_journal_completion_loss
- test_gateway_chat_worker_records_turn_journal_completion
Reverting the predicate gate turns the two new CORE regressions red;
reverting the Gateway write turns the Gateway regression red.
A persisted message_count is only authoritative when the write that
produced the messages array also vouched for it. Sidecars written by an
older writer (recovery materialization copied the denormalized state.db
count over real rows) could carry a count stale against the array beside
it, and the prefix shortcut then reads a real shrink as a growth and
skips the #1558 backup.
save() now stamps _mc_v alongside message_count, and _prefix_message_count
refuses any count without the current marker: those files get the full
parse, exactly as on the legacy path. One save re-marks the file, so the
fast path resumes for steady state.
Regression covers the unmarked stale-count shape and the re-marking
transition; mutation-checked by removing the gate (the unmarked fixture
then skips the backup, as the review's reproduction predicted).
The #1558 backup safeguard in Session.save() compares the on-disk message
count with the incoming one and writes <sid>.json.bak only when the save would
shrink the array. Sound -- but it obtained the on-disk count by
json.loads(self.path.read_text()), the entire file, on EVERY save, including
the 99.9% of saves that grow the conversation and never back anything up.
Measured in production (Mac docker stack), in the running container:
sessions/fa3bca34a0c6.json 203,439,398 bytes / 266,940 messages
read_text 17,453 ms
json.loads 2,924 ms
TOTAL to obtain one integer: 20,377 ms <- paid on every save
That was the 39,357 ms /api/session request stuck at t6_after_json_write,
after the read-side re-parse had already been fixed. It scales linearly with
the file, and the file grows for as long as the conversation does.
The decision below needs ONE integer. save() already writes that integer into
the metadata prefix, before `messages`, as `message_count` -- exactly so
readers can have it without parsing the body, which is how
load_metadata_only() and the sidebar freshness check already work. So save()
now reads THAT, through the same bounded 64 KiB prefix reader, and falls back
to the full read + parse whenever the prefix does not carry a usable count: a
legacy pre-#5854 sidecar whose count serialized after the scenes, a file with
no top-level `messages` key at all, a corrupt or truncated prefix, metadata
alone that overflows the budget, or an unreadable path. Fail-open is the
contract -- never "assume no shrink". The full text is still read for one
thing only: the .bak body, when a shrink is actually detected.
An in-memory stat identity was tried first and is NOT sufficient, so it is
gone. The first revision of this branch stamped the (inode, size, mtime_ns) of
the bytes it wrote and trusted the remembered count while that tuple matched,
on the reasoning that "stat says nothing touched it, so the array is the one I
wrote". Review (nesquena-hermes, 2026-09-21) reproduced the hole: a stat tuple
is not a content identity. A same-length in-place rewrite inside one mtime tick
keeps inode, size AND mtime_ns -- ext4 stamps mtime from a coarse clock, so two
writes in the same tick share one mtime_ns -- and the next shrinking save then
reads a stale count, concludes it is growing, overwrites the longer transcript
and writes no .bak. That is the #1558 data-loss shape the safeguard exists to
prevent, reachable on this branch. A count read from the file's own bytes
cannot be fooled that way: any rewrite of the bytes rewrites the count.
So _disk_identity() is removed along with the identity fields on Session, the
stamp after the write, and the seeding in load(). _prefix_message_count() is
the replacement: bounded prefix -> the number the writer itself recorded.
One trap survives the redesign and is pinned. load() de-duplicates adjacent
partials and immediately saves the shorter transcript (#2592), and that save
must produce a .bak because it shrinks the array on purpose. The count that
decides it is the one the writer stamped on the file -- the ON-DISK length it
wrote -- not the post-collapse length of the object. Read the object's own
length instead and the #2592 backup disappears silently.
session_recovery._msg_count() pays the same cost at boot and is deliberately
left alone. Its "torn file -> -1" contract is what makes recovery restore a
.bak over a truncated live file, and no bounded read can prove a file is not
truncated. A boot pays it once; a save paid it every time.
Tests: 9 in tests/test_save_count_without_full_parse.py. The review's
reproduction is the last one -- a same-inode, same-byte-length, same-mtime_ns
in-place rewrite of 2 -> 5 messages, then a save of 3, which must leave the
5-message array recoverable. It fails against the removed stat-identity design
(re-verified by restoring that revision: no .bak is written and the
5-message transcript is lost) and passes here. Mutation-checked the same way:
recording the post-collapse length fails exactly the #2592 test. 783 tests
pass across the 41 files that touch this surface (save/load, metadata-only,
collapse, .bak recovery, sidecar reconciliation); ruff is clean on the diff.
Two findings from the round-1 maintainer review were not addressed
in round-2: the helper clamped an out-of-range value *up* to the
ceiling, and the env var was exposed to per-profile ``.env``
overrides, which contradicts the "process-wide resource limit"
intent and creates a first-load-wins asymmetry across profile
switches. This round fixes both.
``_read_max_session_resolve_concurrent()`` previously treated
out-of-range values like ``HERMES_WEBUI_MAX_SESSION_RESOLVE=999999``
as "the operator wants the maximum" and returned ``64`` — admitting
64 simultaneous unbounded transcript loads. The round-1 reviewer
measured ~9.6 MB per parse on a synthetic 4.27 MB transcript, so the
old ceiling permitted ~614 MB at 64-way concurrency before any
other application memory.
The cap is a *safety bound*, not a user preference. A typo must
fall back to the safe default, never to the permissive extreme —
matching the behavior of every other bad input in this helper
(missing/empty/zero/negative/non-numeric all already fall back to
2). The 64 ceiling is reachable only via an explicit in-range
operator value.
server-wide key, not a per-profile .env override (SILENT)
The cap controls a single process-wide ``BoundedSemaphore`` that
gates full-transcript resolves. A profile's ``.env`` is per-profile
by design; letting it set a server-wide limit lets a single
profile drag the rest into a tighter or looser posture than the
operator intended, with a first-load-wins asymmetry (a later
profile switch cannot resize the already-constructed semaphore).
The same shape as the isolated-profile key added in #4589: only
the operator/launcher env at startup can set it. The cap is added
to both ``_PROTECTED_ENV_KEYS`` (blocks the profile env at the
load step) and ``_BLOCKED_RUNTIME_ENV_KEYS`` (filters the profile
env in the runtime/gateway-parity path), so a profile cannot
inject the env var at any layer.
1. ``api/models.py`` — ``_read_max_session_resolve_concurrent()``:
- ``if n > 64: return 64`` → ``if n > 64: return 2``
- Docstring updated to spell out the "safety bound, not a user
preference" rule and to point at ``_PROTECTED_ENV_KEYS`` as
the place the 64 ceiling is enforced for profile env.
2. ``api/profiles.py`` — two frozensets:
- ``_PROTECTED_ENV_KEYS`` adds
``'HERMES_WEBUI_MAX_SESSION_RESOLVE'`` (next to
``'HERMES_WEBUI_ISOLATED_PROFILE'``).
- ``_BLOCKED_RUNTIME_ENV_KEYS`` adds the same key, with a
comment explaining the first-load-wins asymmetry.
3. ``tests/test_issue7421_resolve_cap_config.py``:
- The round-1 ``test_env_above_upper_bound_is_clamped_to_64``
is renamed to ``test_env_above_upper_bound_falls_back_to_default``
and the assertion + docstring reflect the new fallback
contract. The maintainer's review said explicitly:
"Update the clamp test accordingly."
- Three new tests pin Finding 2:
- ``test_protected_env_keys_includes_resolve_cap``
- ``test_blocked_runtime_env_keys_includes_resolve_cap``
- ``test_filter_runtime_env_strips_resolve_cap`` (end-to-end
exercise: a profile env containing the cap is filtered out
of the gateway-parity view, and an unrelated, non-blocked
key like ``LANG`` still passes through as a sanity check)
- ``tests/test_issue7421_resolve_cap_config.py``: 15/15 pass.
- ``-k "profile or protect or isolated or HERMES_WEBUI_ISOLATED"``:
866 passed, 5 skipped, 1 xfailed, 2 xpassed — no regression.
- The 5 ruff findings on the worktree are all pre-existing
(3× F401 ``sqlite3`` + 2× B904 ``raise-with-from`` in
``api/profiles.py``); the curated gate is gated on
newly-introduced findings only.
Two. Both are the explicit fixes the maintainer requested.
- ``_read_max_session_resolve_concurrent(999999)`` now returns
``2`` instead of ``64``. The 64 ceiling is still reachable
via an explicit in-range operator value.
- ``HERMES_WEBUI_MAX_SESSION_RESOLVE`` set in a profile's
``.env`` is now ignored: the operator/launcher env at
startup is the only place the cap is configurable.
Both changes are backward compatible at the operator level
(an explicit ``=8`` still returns ``8``) and break the
"typo → ceiling" failure mode and the
"first-loaded-profile-wins" asymmetry the maintainer flagged.
- An operator who set ``HERMES_WEBUI_MAX_SESSION_RESOLVE=64``
in their deployment env is unaffected (still returns 64,
as before).
- An operator who set ``HERMES_WEBUI_MAX_SESSION_RESOLVE=128``
in their deployment env now silently falls back to 2 instead
of getting 64. This is the round-1 reviewer's intent:
"Update the clamp test accordingly" + "the safe default,
never the permissive extreme." A startup log warning could
be added in a follow-up if a louder signal is wanted; this
PR keeps the silent-fallback contract consistent with the
other bad-input paths in the same helper.
- A profile that *had* ``HERMES_WEBUI_MAX_SESSION_RESOLVE=64``
in its ``.env`` and was relying on it to set the cap now
silently uses the operator-set value. This is the intended
behavior: the cap is a process-wide resource limit, not a
per-profile toggle. A profile-side log warning is a
follow-up if needed; the runtime env path now drops the
key, so the failure mode is "operator value wins" rather
than "first profile to load wins".
- #7656 — the original PR; this is round-3 of the same branch.
- #7421 — the original env-override feature.
- #4589 — the same shape ("operator-only, not per-profile")
for the isolated-profile key; the comment block on the new
protected key references the same pattern.
[rebase onto current master — same content as 5a7f7d8, no functional
changes beyond a single context-line merge from master]
Squash-rebase of #7656 onto current master so the 3-way merge CI
test no longer pulls in the unmerged stage/7618-7619-renderer-i18n
dirty diff that 5a7f7d8 was based on. The actual change set is
unchanged: 2 files, +231/-1.
What changed (from #7656 / 5a7f7d8):
- api/models.py::_read_max_session_resolve_concurrent — new
module-level helper that reads the cap from the env var
HERMES_WEBUI_MAX_SESSION_RESOLVE with strict validation
(positive int in [1, 64], malformed → default 2).
- tests/test_issue7421_resolve_cap_config.py — 12 regression
tests covering unset/empty/whitespace, headline cases (4/8/64),
safety paths (0/-1/999999/non-numeric), whitespace tolerance,
BoundedSemaphore capacity check, and a static wiring assertion.
Model: MiniMax-M3
A unique durable row remains one visible row when its sidecar provider payload is stale. Use the state.db payload for replay, cover repeated recovery, and document the image-turn ownership exception.
Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
Use exact-key dedup by default and distinguish only rows with two valid different durable IDs.
Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
Assisted-by: Claude Code:claude-opus-5-5
Match durable row identity as well as content before skipping a recovered image row, preserving separate same-text turns.
Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
Assisted-by: Claude Code:claude-opus-5-5
Reuse the existing unique state-row identity guard before preserving a distinct image projection, so repeated reconciliation does not duplicate the same row.
Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
Preserve the submitted image turn as the display owner while retaining rich Agent context for model replay and suppressing its durable scalar mirror from recovered WebUI transcripts.
Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
Assisted-by: Claude Code:claude-opus-5-5
Re-review #7682 (nesquena-hermes 2026-09-22T19:18:00Z, CHANGES_REQUESTED).
`_transcript_already_advanced_past_pending` suppressed recovery on a
transcript-only heuristic: the first assistant row with final visible text
inside the pending turn's boundary. An interrupted multi-segment turn shaped
like
user -> assistant "Let me check the logs first." (unflagged interim prose)
-> tool -> tool ... (stream dies here)
passed the predicate as "finished" even though the interim sentence is not a
final answer and the stream was cut off mid-tool. `_apply_core_sync_or_error_marker`
then cleared pending state and returned early: the run journal was never
replayed and no interruption marker was added — a half-finished turn rendered
as complete, journaled tool output never appearing.
Fix: a genuine final answer inside the turn is now NECESSARY BUT NOT
SUFFICIENT. The suppression also requires durable same-stream terminal
evidence — `_run_journal_terminal_state(session, active_stream_id) == 'completed'`
(a `done`/`stream_end` event). Without it, fall through to the normal
journal-recovery path so the partial output is replayed and the interruption
is marked.
Regression tests (red/green verified — both fail with the gate removed):
- test_does_not_advance_when_unflagged_interim_prose_precedes_tools
- test_full_recovery_runs_journal_replay_for_unflagged_interim_prose
(drives `_apply_core_sync_or_error_marker`, asserts journal replay +
interruption marker present)
- test_does_advance_when_journal_marked_done_after_interim_prose (positive)
Existing `_FakeSession` fixtures updated to carry a session_id and write a
`done` journal event so the durable-evidence gate is satisfied for the
settled-turn tests.
Verification: test_issue6366 35 passed; anchor_scene + cancelled_turn
neighbours 91 passed. Red/green proven on both regressions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The env override never reached /api/sessions because the route kept a
separate hard-coded cap of 20. Resolve the window in api.config before
profile init, clamp it at 200, and refuse a profile .env override.
A user prompt could render at the very bottom of a conversation, below the
assistant turn that had already answered it.
merge_session_messages_append_only reconciles the sidecar with state.db. When a
state.db row carries a typed row id that is unique to state.db and absent from
the sidecar (the two stores do not share an id space),
row_id_preserves_source_multiplicity is true and the row bypasses the
sidecar-timestamp-range block -- including the
_insert_state_message_chronologically call that block owns. It then fell through
to the terminal merged_messages.append and landed at the tail regardless of its
timestamp.
The full display path re-sorts by timestamp afterwards
(_merged_webui_lineage_messages_for_display), which hid the defect. The
paginated path used for the initial render (msg_limit) does not, so the user saw
their own message below its own answer until a full reload reshuffled it.
Place such a row in its chronological slot when its timestamp predates the
sidecar tail, falling back to append when no safe slot exists. Rows that are
genuinely newest still append at the tail, so the append-only contract (never
drop a local row) is preserved.
Verified on the reported transcript: the merged tail now ends on the assistant
conclusion instead of the orphaned prompt, with the same message count.
The #6366 suppression was too broad and could destroy data in two ways,
both verified with full `_apply_core_sync_or_error_marker()` probes:
1. A repeated prompt could be silently discarded. Checkpoint matching
truncates timestamps to integer seconds and ignored
`_active_turn_token`, so a prior identical prompt from a *different*
stream could match the new pending turn — and the suppression branch
then cleared the only durable copy of the prompt, with no recovered
row and no error marker.
Identity is now bound to the pending stream's exact active-turn token
(`_transcript_user_row_is_pending_turn`). Integer-second timestamp
equality never overrides a token mismatch, and a row that carries no
token cannot be proven to be this turn. Legacy sessions with no token
on either side fall back to the strict content + timestamp + source +
attachments checkpoint.
2. Interrupted tool-running turns were treated as completed. Any
unflagged assistant row counted as settled, including empty rows,
tool-call-only rows, interim-progress rows and compaction rows, so
`:3600` bypassed journal replay and the interruption marker.
Completion now reuses the established final-answer semantics
(`_pending_turn_has_final_assistant_answer` ->
`_assistant_message_has_final_visible_text`) inside the matched
user-turn boundary, rejecting tool-only, empty, interim, journal-
recovered and compaction tails.
The suppression is deliberately asymmetric: a false negative leaves a
visible cosmetic duplicate the user can ignore, while a false positive
silently destroys a prompt or a response. Whenever identity or
completion cannot be positively proven, recovery runs instead of
suppression.
Tests: the 11 predicate unit cases are re-derived against the new
identity/completion helpers, plus full-recovery-path cases that exercise
`_apply_core_sync_or_error_marker()` itself — distinct same-second
repeated turns (token-bearing and legacy) proving the prompt survives,
and empty / tool-call-only / interim / compaction tails proving journal
replay and the interruption marker still run — plus a positive control
for the suppression branch itself.
Stale-cancel recovery in _apply_core_sync_or_error_marker
(line ~3497+) only inspects the transcript tail
(`session.messages[-1]`) when checking whether the durable
transcript already contains the pending turn. When the pending
turn's user row is older than the tail, the tail is a *newer*
settled assistant row that can never match the pending **user**
checkpoint, and the recovery path falls through into the append
branch. The cancel/recovery tail then appends, after the valid
final answer, a recovered user row, a `_partial` clone of the
old journal, and a generic no-response error that survives reload
in the durable transcript.
The fix adds `_transcript_already_advanced_past_pending()`
which scans the messages for any strictly-matching user row
(content + timestamp + source + attachments) and, when one is
found, looks for a later settled assistant row (no
`_partial` / `_error` / `_recovered`). When that is the
case, the helper returns True and the non-completed-journal
branch clears only the stale pending fields and returns
without appending any rows.
Strict match is required so that a *new* user turn that happens
to repeat the same prompt text (e.g. user re-sends the same
question) is not misclassified as a stale advance. The
regression guard is the existing
`test_repeated_pending_prompt_uses_current_checkpoint_identity`
which now passes alongside the new helper unit cases.
The helper is a pure read; running recovery twice therefore
produces the same outcome, satisfying the maintainer's
idempotence requirement.
Validation: 11/11 new tests pass (covers the strict-match
distinction, the partial/error/recovered skip, and idempotence);
124/124 related stale-pending / sidecar-repair / recovery tests
pass; py_compile clean.
CLI_VISIBLE_SESSION_LIMIT is hardcoded to 20. That constant also bounds
how many delegated subagent children can render at once, since a child
only nests when its parent row is in the same payload, so operators
running wide delegate_task fan-outs cannot see all children without
editing the source.
Read it from HERMES_WEBUI_VISIBLE_SESSION_LIMIT via the existing
_env_int helper, keeping 20 as the default.
The webhook second-pass loop in _load_cli_sessions_uncached called
get_last_workspace(profile=_cli_profile) directly per row instead of the
_cli_workspace() closure the main/cron/kanban passes already use, so it
paid its own redundant file reads/is_dir() probe on every webhook row even
though the loop already sits inside the profile-home resolve cache scope.
Flagged in PR #7636 review (greptile): this is the one concrete gap in an
otherwise-correct fix.
Also adds an integration-level regression test proving the actual N+1 this
PR targets -- Session.__init__ constructed via
_state_projection_sidecar_metadata's Session.load_metadata_only() fallback
on a cold sidecar-metadata cache -- is deduped by
@profile_home_resolve_cache_scope() on _load_cli_sessions_uncached, with a
companion test showing the same fixture scales linearly (one filesystem
resolve per row) when the scope is bypassed. This directly answers the
review claim that the cache scope doesn't reach any Session construction:
it does, verified by counting real _safe_resolve() filesystem calls rather
than call counts on the memoizing wrapper alone.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The process-lifetime cache from the previous commit was flagged in PR
review (nesquena-hermes): Path.resolve() is a filesystem call, not a
pure function of process-startup constants. A profile-home symlink can
be retargeted while the long-running webui process stays up, and
_safe_resolve() deliberately falls back to the unresolved input after a
transient OSError/RuntimeError/ValueError -- a process-lifetime cache
would serve either a stale symlink target or a stale fallback forever.
Redesign: replace the module-level dict with a contextvars.ContextVar
that defaults to None (no active scope -> resolve fresh, exactly like
pre-PR behavior). profile_home_resolve_cache_scope() sets it to a fresh
dict for the duration of one call and resets it in a finally block, so
no cache entry survives past that call. It now wraps only
_load_cli_sessions_uncached as a decorator -- the actual N+1 hot loop
(hundreds of Session.__init__ calls per sidebar build, each resolving
the same profile argument). All other call sites of
_resolve_profile_home_param (confirmed via repo-wide grep: all internal
to api/workspace.py, none elsewhere) get fresh resolution on every call,
same as before this PR existed.
Rewrote tests/test_profile_home_resolve_caching.py to prove: (a)
repeated resolves within one scope still hit the cache once, (b) two
separate scopes (simulating two requests) do not share state -- a
symlink change between them is observed on the second scope, and (c)
calls outside any scope never cache at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The all-profiles sidebar scan walks every profile's state.db in one
request. When it lazily minted a "Webhooks" / "Cron Jobs" project for a
profile that had webhook/cron rows, ensure_webhook_project() and
ensure_cron_project() tagged the new entry with get_active_profile_name()
-- the UI-selected profile -- instead of the profile whose rows triggered
it. A webhook-only profile therefore leaked a "Webhooks" project onto
whichever profile was selected; deleting it did nothing because the next
sidebar poll re-minted it, and switching profiles minted one per profile.
ensure_webhook_project / ensure_cron_project / _profile_has_user_projects
now take an explicit profile (default: active profile), and the scan
passes _cli_profile through, so the opt-in gate and the tag both key on
the scanned profile. Existing stubs in tests updated to accept the kwargs.
Read the metadata prefix in staged geometric reads so one oversized metadata field no longer forces a full re-parse of the whole sidecar.
Co-authored-by: rodrigogs <rodrigogs@users.noreply.github.com>
Decode the state.db content sentinel in the WebUI message projection so structured/multimodal content renders instead of a placeholder, with an out-of-band tuple identity that no scalar can forge and a per-call ContextVar memo for the canonical serialisation.
Co-authored-by: totalitarian <totalitarian@users.noreply.github.com>
Closes the remote POSIX workspace path preservation gap across Session and streaming. Profile-aware runtime path resolution.
Co-authored-by: alfred-rootson <alfred.wojtasordzik@gmail.com>
Preserve nested request isolation, make the response allowlist single-source,
and strip heavy fields from existing index rows after reconciliation.
Co-authored-by: Martin Dell <martindell@users.noreply.github.com>
The open/import transcript stitcher (get_state_db_session_messages with
stitch_continuations=True) runs its own lineage SELECT that omitted
session_source/model_config, so the shared _is_continuation_session guard
added for the listing path never fired there: an explicit branch/delegate
child (model_config._branched_from/_delegate_from) starting within the 2s
tolerance window got its rows stitched into the parent transcript on open.
Select both identity columns when the schema has them (NULL AS fallback for
older state.db schemas), so the same guard applies in the stitch path.
Tests (tests/test_session_lineage_metadata_api.py):
- branched/delegate children inside the tolerance stay out of the parent
transcript on open/import (fail before this fix, pass after);
- old-schema rows without the identity columns still stitch genuine
compression continuations (no crash, no behavior change).