The project-scoped recovery pass asked for the global window's exhaustion
signal but never for its own. A scoped query carries a raw candidate window
of its own (limit * 8, re-widened to limit * 32), and compression segments
plus the post-projection visibility filters are spent from that window
BEFORE the logical slice. A lineage-heavy project therefore came back short
with its window fully consumed while older assigned conversations of the same
project still waited behind it, and the loop advanced to the next project —
leaving that project under-delivered on every rebuild.
The scoped query now asks for `return_window_exhaustion`, and a binding
window buys exactly one re-query at the widest limit this pass is allowed to
scan (`query_limit`). The retry is bounded to one extra query per starved
project and cannot out-read the pass's own scan budget.
The regression test builds a starved project whose scoped raw window is
filled by its own compression segments, with an older conversation of the
same project just behind the window. Against the previous head that
conversation was lost — the project recovered only its chain tip — while the
new head recovers it and pays exactly two scoped queries (limits 4 then 8:
one widening step, inside the ceiling). Verified red without the fix and
green with it; the full file is 73 passed, and the sibling suites this branch
touches (test_issue2628_cli_sessions_perf, test_issue4842_cron_projection_perf)
bring the run to 86 passed.
The unassigned refill's proportional widening degenerated in two shapes, and
both spent the forced ceiling query -- 200 conversations read, sidecar-stat()ed
and merged into the payload -- to deliver a 20-row window.
* A refill query that finds ZERO still-unassigned conversations has no density
to extrapolate from, but max(unassigned_seen, 1) evaluated the estimate as
target * width anyway (20 * 40 = 800, clamped to the ceiling). Any profile
that had moved >= 21 of its newest conversations from the WebUI went straight
to a full 200-row read where the 60-row exact-width floor fills the window on
the nose. The proportional term now applies only when the window in hand
actually saw an unassigned conversation.
* When the moved conversations cluster just below the window edge, every
re-count returns the same unassigned count, so the estimate advanced ~2 rows
a time (21, 23, 25): all four queries spent and the last reading the whole
ceiling band. The widening is now also geometric, paced by the queries still
left (width // queries_left, so width/3 then width/2) -- bolder as the forced
ceiling read gets closer, because a too-narrow last estimate costs that whole
read while a too-wide one costs only its own surplus.
Measured on a 5000-conversation state.db (warm cache), unassigned query widths
/ rows the model carries:
* newest 40 moved: [40, 200] 200 rows -> [40, 60] 60 rows, 400 -> 160 stat()
* 8 moves clustered at the bottom edge of 200: [21, 23, 25, 200] 200 rows /
192 unassigned -> [21, 28] 28 rows / 20 unassigned
* every clustered size up to 22 moves is off the ceiling path (42 rows, three
queries); 23+ still ends on the ceiling read, as before
* no WebUI-side moves: unchanged, no refill query at all
Unchanged by construction: the widening is still strictly increasing so the
loop cannot stall, the query count is still bounded by
UNASSIGNED_CLI_REFILL_MAX_QUERIES, and the last query still reads the full
ceiling band -- so the window still fills whenever at least 20 of the newest
200 state.db-unassigned conversations are unassigned once sidecars are read.
The one deliberate cost: a profile whose newest ceiling band is ENTIRELY moved
now climbs (40, 60, 90, 200) instead of (40, 200), paying two extra narrow SQL
queries for the same 200-row payload.
The trade-off note is rewritten with it. "A few spare conversations" was the
best case (24 rows for 20); the worst case inside the same bound is the whole
ceiling band, so the note now states that, plus the two things the old bound
omitted: CANDIDATE_WINDOW_MULTIPLIERS = (8, 32) means a ceiling-width query
touches 1600 raw rows (6400 re-widened), and every bound here is per profile
context, not per sidebar build -- all_profiles=1 runs the loader once each.
The refill's allowance for sidecar-carried assignments was computed once,
from the rows fetched BEFORE it ran, so every moved conversation the refill
itself reached spent an unassigned slot again -- the failure the allowance
was added to remove:
* 30 conversations, 3 moved from the WebUI at the bottom edge of the 20-row
window (cli-08..cli-10): 19 unassigned rows in the payload, and cli-07 --
genuinely unassigned, present in state.db -- never fetched at all;
* 40 conversations, 11 moved straddling the boundary (cli-15..cli-25): 15
unassigned rows, with cli-09..cli-13 never fetched.
Both deliver 20 now. The refill re-classifies its OWN result and widens
again while the window is short. It widens proportionally (by the moved
share observed so far), not one row at a time, so it converges for moves
that cluster as well as for moves spread through the history.
Bounds per sidebar build: at most UNASSIGNED_CLI_REFILL_MAX_QUERIES (4)
unassigned queries, none reading deeper than
UNASSIGNED_CLI_REFILL_SCAN_CEILING (200) conversations, stopping as soon as
the window is full or the filter demonstrably has nothing deeper to give.
The last of the four reads the whole ceiling band, so the window fills
whenever at least 20 of the newest 200 state.db-unassigned conversations
are still unassigned once their sidecars are read. Past that (>180 of the
newest 200 moved from the WebUI) the sidebar is honestly short instead of
paying unbounded I/O -- that boundary is stated in the code and pinned by
a test.
No I/O per row is added: the re-count reuses the scan's memoized sidecar
and project-catalog caches (#4842).
The review asked for "confirming the project still exists for the
session's profile". profile_scoped_project_ids() read
get_active_profile_name(), but _load_cli_sessions_uncached runs once per
profile context in the ?all_profiles=1 view, so every context resolved
against the ACTIVE profile's catalog.
A LIVE assignment owned by another profile was therefore misread as
unresolvable: coerced to None, skipped by the assigned recovery pass, and
unreachable by the unassigned refill too (its state.db row does carry a
project_id, which the "unassigned" SQL filter excludes), so the row left
the payload entirely — while its chip stayed selectable in that same view,
because /api/projects returns every project when all_profiles=1.
Thread the ctx_profile already available at the call site through to the
catalog read. The safe direction is unchanged: an id that belongs to a
different profile, or to no project at all, still coerces to None.
/api/session/move writes project_id onto the WebUI sidecar only; nothing
writes state.db.sessions.project_id. The model's unassigned counter read
state.db exclusively, so it believed all 23 conversations were unassigned
and stopped at its 20-row window — while the route, which sees the
sidecar's project_id through all_sessions(), classified the 3 moved
conversations as ASSIGNED. Net result: 17 unassigned CLI rows in the
payload and cli-00/01/02 never fetched at all, the reviewer's exact
3 + 20 -> 17 number.
Resolve the assignment from the source of truth the route ends up using:
_state_projection_sidecar_metadata (already the UI-owned title/archived
reader, already stat-cached) now also returns project_id, and
_interactive_row_project_id falls back to it when state.db has none. The
unassigned refill query is widened by the number of sidecar-only
assignments, because the SQL 'unassigned' filter cannot see them and would
otherwise spend those slots re-fetching rows already represented.
The review asked for "the existing 200-row assigned cap across the final
merged CLI set". The per-project budget was only tightened by an equal
share of a 2000-row ceiling, so it bit past 10 assigned projects and left
the review's own reproduction intact: 1,000 assigned conversations spread
over 5 projects returned all 1,000 (2000 // 5 = 400, every project stayed
under its own 200).
Bound the merged assigned payload at CLI_PROJECT_ASSIGNED_CAP and spend
that budget with a round-robin draw across the projects, newest first
within each project, so bounding the payload cannot starve a quiet project
the way a flat sessions[:200] truncation would (greptile P1).
CLI_PROJECT_ASSIGNED_SCAN_CEILING is gone: 200 is strictly tighter than
the model's 2000-row scan ceiling, so mirroring it here was dead. Its
drift guard is replaced by a stronger one pinning the route cap to
models.PROJECT_ASSIGNED_CLI_LIMIT and to <= the model scan ceiling.
The route cap bounded project-assigned CLI rows PER PROJECT at 200, which
is not a bound on the payload: 200 rows times N projects grows with the
project count. The review's own reproduction still held — 1,000 assigned
conversations spread over 5 projects returned all 1,000, because no
project exceeded its own 200 (#6659 review finding 1). Imported WebUI
sidecars from all_sessions() never pass through a state.db cap, so this
merged pass is the only place that can bound them.
Give each project an EQUAL share of a global CLI_PROJECT_ASSIGNED_SCAN_CEILING
(ceiling // projects, never below 1) once enough projects exist to make that
share tighter than the per-project cap. Same shape as the model-side share of
PROJECT_ASSIGNED_CLI_SCAN_CEILING, and for the same reason a flat global cap
is unusable here: it would leave every project past it permanently
unreachable on each rebuild (greptile P1). Profiles with a few projects keep
the full 200, so the common case is unchanged.
The ceiling is duplicated as a literal because api.models is imported below
this cap in api/routes.py; a test pins the two equal, mirroring
test_background_source_exclusion_literal_matches_the_constant.
The GROUP BY probe that finds the projects a global assigned query starved
only fired when that query came back logically FULL. A query can instead
stay short because its bounded raw candidate window was entirely consumed
by compression segments and hidden rows, while older assigned
conversations still exist below it. Read as "the database has nothing more
to give", that shortfall skipped the probe and left the quiet projects
unreachable.
Have read_importable_agent_session_rows report whether the final raw
candidate window was exhausted while its logical projection stayed short
(opt-in via return_window_exhaustion, so the list API is unchanged for
every other caller), and gate the probe on saturation OR that exhaustion.
A short result from an unfilled raw window has genuinely seen every
candidate, so the common one-global-query fast path still pays no probe.
The refill after a saturated global assigned window was cut to the first
32 starved projects in deterministic neediest-first order. The recovery
is re-derived from state.db on every sidebar build, so the same 32 won
every time and every later project stayed unreachable on each rebuild —
the exact starvation the pass exists to repair (greptile P1 on #6659).
Drop the project-count cap. The follow-up's work is bounded by
construction instead: it only fires when the global window came back
saturated, each scoped query is limited to one project's remaining
budget, and the sum of those budgets across every starved project is at
most effective_limit * len(projects) <= PROJECT_ASSIGNED_CLI_SCAN_CEILING.
A fixed cap bounded nothing that the per-project limits did not already
bound; it only decided which projects were permanently left out.
The per-project budget was applied to the rows of ONE global recency-ordered
query, so it was not a per-project bound: when a single project owns the whole
`limit * len(projects)` window, every other project's assigned conversations are
never considered and their chips show nothing (greptile P1 on #6659, reproduced
at the real 200 as well as scaled down).
Two tiers now:
* the global query still runs first and is the only cost a profile whose assigned
history fits in the window pays;
* when it comes back SATURATED, one GROUP BY over sessions.project_id says which
projects still own undelivered rows, and each of those gets its own
project-scoped query with its own remaining budget. `project_ids` narrows the
lineage CTE's seed, so a per-project query is cheaper than the global one and
still returns whole compression lineages.
Cost stays bounded: worst case per build is 1 global query + 1 GROUP BY probe +
PROJECT_ASSIGNED_CLI_REFILL_PROJECTS (32) project-scoped queries, each bounded to
one project's remaining budget. The scan ceiling is now shared equally between
projects (ceiling // len(projects)) rather than first-come, so the recovered
payload stays under the ceiling without letting the loudest project take it all.
profile_scoped_project_ids() duplicated the body of the canonical
api/profiles.py::_profiles_match, whose docstring says it was relocated there
precisely so callers stop duplicating it. Call it instead.
Behaviour-identical: both spellings agree on all twelve None / '' / 'default' /
renamed-root / foreign-profile combinations, which the new parametrised test
asserts cell for cell against _profiles_match itself. It is a drift guard, not
a regression test — swapping the call back to a raw
`p.get('profile') == active` comparison reddens seven of its twelve cells.
_merge_state_row overwrote an already-represented lineage unconditionally, and
recovery pass 2 (the unassigned refill) merges into the same lineage-keyed
table pass 1 fills. A row already carrying a RESOLVED project_id could
therefore be replaced by a bare unassigned projection — deleting the chip this
PR exists to restore and swapping the row's identity from the lineage tip back
to its root.
Reachable wherever the SQL project_assignment filter and the Python lineage
projection disagree about where a compression lineage starts. They differ today
on a blank `source`: _is_continuation_session treats an empty/whitespace source
as "unknown, same conversation", while the lineage CTE compares
LOWER(TRIM(parent.source)) = LOWER(TRIM(child.source)) and so will not walk
from an assigned tip up to a blank-source root. The mixed first pass collapses
the pair into one assigned row; pass 2 then hands the root back as its own
"unassigned" conversation and clobbers it.
The merge is now one-way: overwrite only when the incoming row ADDS a resolved
assignment, or when the row on file has none to lose. Fixing the merge rather
than each possible divergence — it is the one place where "already resolved" is
knowable locally, and pass 2 by construction has less assignment context than
pass 1.
tests/test_issue2841_show_cron_sessions_toggle.py reads this call site as
SOURCE TEXT (`'("cron", "webhook", "kanban") if source_filter is None'`), so
routing it through the new BACKGROUND_CLI_SOURCES constant turned a
pre-existing test in the maintainer's own shard gate red for a
behaviour-neutral refactor.
Put the literal back at that one call site. BACKGROUND_CLI_SOURCES stays where
the kanban-chip fix actually needs it (the background-source check in
_interactive_row_project_id, and interactive_excluded for both recovery
passes). A new test builds the expected literal FROM the constant and asserts
it appears verbatim in api/models.py, so the two spellings cannot drift
without failing here rather than in an unrelated file.
Recovery pass 2 was gated on `first_pass_count >= unassigned_target`, reading a
short first pass as "the database has nothing left". It can also mean the mixed
pass ran out of RAW candidate window: compression segments are spent from it
before the logical slice, so a lineage-heavy profile gets fewer conversations
than it asked for and the unassigned quota stays short. Also run the refill when
assigned conversations inside the window displaced unassigned candidates, which
is the case a narrower unassigned-only query can actually repair. A profile with
no assignments and a small state.db still issues no extra query.
While here, spend the per-project assigned budget through `_merge_state_row`'s
return value instead of pre-incrementing, so the budget counts logical
conversations the way PROJECT_ASSIGNED_CLI_LIMIT documents, and seed it from the
assigned rows already inside the window so the bound stays a total rather than
one bound stacked on another. That accounting is behaviour-neutral today (the
assigned query is newest-first, so window rows are always the first rows it
returns) — it makes the documented contract hold by construction rather than by
that ordering accident, and removes an unused return value.
`_interactive_row_project_id` resolved state.db assignments for anything that
was not cron or webhook, but the interactive projection loop is also what a
`source_filter='kanban'` scan walks. The same kanban row therefore reported two
different chips depending on the caller: None in the default sidebar (its own
bounded second pass, upstream behaviour) and a resolved project_id under
?source=kanban.
Route every source that owns a bounded project-chip pass back through
`_state_row_project_id`, and name that set once (BACKGROUND_CLI_SOURCES) so the
exclusion list and the chip decision cannot drift apart.
`?all_profiles=1` passed `project_assigned_limit=False`, on the premise that
its `visible_session_limit=None` first pass already returns every conversation.
It does not: `_load_cli_sessions_uncached` maps `visible_session_limit is None`
to CLI_VISIBLE_SESSION_LIMIT for the interactive pass, so that view truncates
by recency exactly like the single-profile one. Only cron/webhook/kanban limits
reach the reader as `limit=` directly, where None does mean unbounded.
Net effect of the old value: in the "All profiles" sidebar a project-assigned
CLI session older than the newest 20 interactive conversations was missing from
/api/sessions while its project chip still claimed it — the #6659 bug itself,
in the one view that shows every profile at once. Recovery pass 2 (the
unassigned refill) still ran there, so the two passes disagreed as well.
Pass PROJECT_ASSIGNED_CLI_LIMIT instead of None so the per-project bound and
the PROJECT_ASSIGNED_CLI_SCAN_CEILING guard apply per profile, rather than
issuing an unbounded assigned scan for every profile on the box.
Letting assigned sessions past the *recent* cap was right; letting them past
*all* bounds was not. Three linked defects from review of #6659:
1. The assigned list was effectively unbounded. models.py's 200 cap only applied
to state.db's own pass; imported CLI sidecars from all_sessions() never pass
through it and _cap_recent_cli_sessions() retained every assigned row (1,000
assigned rows -> 1,000 returned). The FINAL MERGED payload is the only place
that sees every source of assigned rows, so bound it there - per project, so
one busy project cannot evict another's history (the >200-assigned case
greptile flagged), and counting logical conversations, not raw rows.
2. Assigned rows spent the unassigned quota: 3 assigned + 20 unassigned yielded
17 unassigned, and the model fetched 20 rows total up front so bumping only
the route counter could not refill. Count the two separately in the route cap
and add a bounded unassigned refill pass, keyed on the lineage, so 20 unique
unassigned logical conversations survive sidecar/lineage dedup. The refill
only runs when the window was actually saturated AND came up short.
3. A deleted or cross-profile project_id hid the session outright: it was
surfaced unchecked, so the row left "Unassigned" and its overflow became
default_hidden with no chip left to reveal it. Resolve non-system ids against
profile-scoped load_projects() and coerce a miss to None BEFORE either cap -
the mirror of the bug this PR fixes.
read_importable_agent_session_rows() grows a `project_assignment` filter
('assigned'/'unassigned') replacing the require_project_id boolean. Both arms
key on the whole compression lineage and are exact complements, so neither
budget can double-count a lineage.
_state_row_project_id() keeps its (sid, source) signature: master's kanban pass
calls it that way, and giving it a row-dict signature turned that whole pass
into a swallowed TypeError that dropped every kanban row.
read_importable_agent_session_rows()'s `limit` is a bound on LOGICAL
conversations (the slice runs after lineage projection), but the bounded
candidate CTE spends its raw window on compression segments and rows the
post-projection visibility filters drop. A lineage-heavy profile therefore got
back far fewer conversations than it asked for, and silently: with twelve
ten-segment chains, limit=20 returned 8.
Retry once with a wider window when the first one was fully consumed AND still
under-delivered. The common case is unchanged - the first window is still 8x the
requested limit and the retry never runs when the db simply has nothing more to
give - so the #2628 capped-scan guarantee holds.
compression_tip() resolves a project assignment across the whole lineage, so it
can find one recorded on a newer EMPTY continuation. When the root is still the
freshest *importable* segment, the `tip is row` early return appended the root
without applying that resolved id and the assignment was silently dropped.
Copy the root and apply lineage_project_id before appending, matching what the
merged-tip path below already does.
Two independent 20-session caps hid older project-assigned CLI sessions:
models.py only queried the 20 most recent, and routes.py capped the API
payload again. Fixing the query alone was not enough.
- agent_sessions.py: optional project_id filter; propagate the freshest
project_id along a compression lineage (compression_tip now returns a
3-tuple) so a Project set only on the tip is not lost
- models.py: second bounded query for older project-assigned sessions;
skipped when the schema has no project_id; dedupe by id/lineage
- routes.py: keep the 20 recent, drop unassigned overflow, preserve
assigned overflow as default_hidden (revealed by the project chip)
Adds regression tests for the limit, the root/tip lineage cases and the
route cap.
Layer three additive pieces on top of the #7021 / #6931 continuation
predicate without changing its shape or CONTINUATION_STARTED_AT_TOLERANCE_SECONDS:
- _reset_from is a boundary marker alongside _branched_from/_delegate_from
(Hermes Agent stamps it in gateway/session_recovery.py and filters it in
_NON_CONTINUATION_CHILD_FILTER_SQL). The same predicate drives the sidebar
listing, lineage metadata/report and the transcript stitcher.
- _branch_markers() returns ('none' | 'markers' | 'unknown', markers);
unparsable, non-object, too-deep, or non-string marker payloads are
'unknown' and fail closed as a lineage boundary instead of degrading to
"no markers" (fail open).
- gateway_watcher._snapshot_hash() hashes the complete canonicalized published
payload, and the cheap fingerprint covers model_config, so a projection change
to title/created_at/metadata with fixed (id, updated_at, message_count) emits
sessions_changed.
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
Review gate on #7720 (two regressions reproduced against master with the
installed Agent):
1. Deleting a profile's last MCP server left ADOPTED tools callable after
/reload-mcp. The scoped teardown only stops connections the profile owns,
and the Agent's discover_mcp_tools() returns before its reconcile step
when the profile's config has no server, so an adopted shared connection
was never judged against the config. /reload-mcp now calls
register_mcp_servers({}) under the profile's scope before rediscovery:
the Agent's overlay heal detaches only this profile's overlay for servers
its config no longer has; the owner's connection keeps running (master's
wildcard shutdown stopped the owner too).
2. Portable plugin MCP servers are merged by the Agent at runtime and never
appear in the profile's raw ``mcp_servers``; the registry fallback
required a config entry and dropped every plugin tool from
/api/mcp/tools and the Notes inventory. The profile's own registry slot
(registry_tool_owned_by_view) is the isolation check; the config is not
an allowlist any more.
Tests (red before, green after): agent-free unit tests for both (the fake
agent now models the overlay heal and discovery's empty-config early
return), and a real-Agent integration test for the adopted-then-deleted
case (owner connection survives, adopter slot emptied, "Removed" reported).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review follow-up on #7720 (Greptile P2): the external Notes drawer reads the
same profile-bound MCP inventory as the MCP panel but dropped the
``runtime_scope`` label, so a profile whose runtime scope could not be
confirmed rendered its note sources as inactive/configured with no
explanation. ``/api/notes/sources`` now returns ``runtime_scope`` and the
drawer shows the existing ``mcp_runtime_scope_unavailable`` notice (same key,
no new locale strings) when it is ``unavailable``.
Tests: the skewed-routing unit test now also asserts the notes response
carries ``runtime_scope: "unavailable"`` with its sources inactive.
docs/troubleshooting.md mentions the drawer next to the panel.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two profiles configuring a same-named MCP server shared one connection and
the MCP settings panel showed another profile's runtime ("Active, N tools"
next to an empty inventory); /reload-mcp stopped every profile's servers and
rediscovered from the process-default config.
- api.profiles._set_hermes_home() is the single writer of the process-profile
home: it records get_process_profile_home() and hands it to Hermes Agent's
hermes_constants.pin_process_hermes_home when available, at startup and on
switch_profile(process_wide=True), so the per-turn HERMES_HOME mirror no
longer makes every turn look like the process profile (bare-name,
cross-profile MCP keys) and the anchor cannot drift from HERMES_HOME.
- Run get_mcp_status/registry reads and /reload-mcp inside
api.mcp_runtime.mcp_runtime_scope(): request profile's Hermes-home override
and secret scope (root included), no os.environ mutation, restored on exit.
- One ledger predicate, ledger_key_serves_view() (owned via
_server_scope_keys/key scope, or adopted via _server_tool_scopes), is shared
by status, inventory and the reload summary. The Agent's launch-profile view
is process-wide and name-indexed, so filter_runtime_status_to_view()
rebuilds connected/tools/status/error from the entries serving the profile:
the root profile never shows a routed profile's connection (nor lets its own
parked failed-spawn task borrow a routed profile's live same-named one).
- Inventory lists only tools in the profile's own registry slot for servers it
configures; responses carry runtime_scope.
- Fail closed when the agent's routing does not match the resolved profile:
hide runtime data (runtime_scope "unavailable", shown as a notice in the MCP
panel, 14 locales) and refuse /reload-mcp.
- /reload-mcp shuts down only the profile's own ledger names (parked
included), drops its own connect cooldown/error entries so a failed spawn is
retried (the wildcard cleared every cooldown; a scoped shutdown only clears
the live keys it tears down), rediscovers under the profile's home and
counts live connections only. The bare wildcard remains for agents without
profile-scoped MCP.
- Signature introspection is centralized (accepts_keywords) and falls back to
the bare call instead of blanking every status; ledger key helpers resolve
to the Agent's tools.mcp_tool_scope when present; tools.mcp_tool is
resolved by full name so stubbed agents still work.
- ARCHITECTURE.md §4.10 and docs/troubleshooting.md document the boundary.
- Tests: agent-free contract (models adoption, parked tasks, cooldowns and the
process-wide root view) and real-Agent integration (fake stdio MCP server,
skips without hermes-agent + mcp SDK).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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
Project conflicting provider payloads after eligible parent-only display
rows are stitched, so a partial continuation keeps the parent bubble and
its attachments without duplicating the durable user row.
Assisted-by: Hermes Agent:gpt-6-sol
Assisted-by: Codex:gpt-6-luna
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.