51 Commits
Author SHA1 Message Date
Rodrigo Gomes da Silva f62cdfe77a fix(sessions): reserve the recent window before the fair assigned draw
The fair draw spent the whole assigned budget round-robin before the
recent cap was applied, and the keep loop then dropped every assigned
row it had not drawn. When the newest conversations cluster in one
project, that project loses its undrawn newest rows: with 11 projects x
20 sessions and all 20 newest in project-00, p0-19 appeared in neither
the default payload nor behind its project chip — a session the base
displays (2026-09-24 re-gate reproduction).

The rows the recent window itself would show (the first cli_cap CLI
rows in sort order, assigned or not) are now RESERVED before the draw,
and the remaining assigned budget spreads round-robin over the undrawn
rows only, so the reservation cannot double-spend a slot. Assigned rows
stay bounded by CLI_PROJECT_ASSIGNED_CAP in total.

The regression pins the literal reproduction; removing the reservation
makes it fail with the row dropped, as reported. The draw-first pins
were re-anchored to the reserve-then-draw contract.
2026-09-24 11:24:18 -03:00
Rodrigo Gomes da Silva 3d0a14e8e0 docs(sessions): correct the merged-assigned-cap comment to the model literal 2026-09-24 11:03:47 -03:00
Rodrigo Gomes da Silva b280455b14 test(sessions): compose the assigned caps with the configured sidebar window
master made the recent-session window configurable (#7631) and dropped the
route-level hard-coded 20. Assert the assigned-retention layer against the
RESOLVED cap (routes._cli_visible_session_cap()) everywhere, drop the three
references to the removed CLI_VISIBLE_SESSION_CAP constant, and add a test
that sets HERMES_WEBUI_VISIBLE_SESSION_LIMIT's value to a non-default 5 and
proves the project-assigned rows stay chip-reachable past that window.
2026-09-24 11:03:47 -03:00
Rodrigo Gomes da Silva 0fba8b9654 fix(sessions): share the widening pool fairly across starved projects
The pass-level widening pool introduced by the previous commit was
billed per whole grant: the first starved project whose binding raw
window asked for a retry had its query widened to the full grant and
the pool was debited the entire width, even though the project only
read grant - share rows beyond its own share. Every later starved
project then found the pool empty, got no retry, and its conversations
behind its own raw window stayed absent from its sidebar on every
rebuild (greptile P1 on #6659, 2026-09-22 re-review).

Two changes, both inside _load_cli_sessions_uncached's recovery pass:

- the pool is debited only for what a retry READS beyond the project's
  own first-query width (widened - scoped_limit), not the grant; and
- each retry is funded by a fair share of what the pool still holds —
  one slice per starved project not yet tried — so a later project
  always keeps pool for its own first retry.

Granting min(query_limit, pool) whole would have re-created the same
starvation by reads instead of by billing: a project whose raw window
is consumed re-reads roughly its own width once before delivering, so
an honest retry must buy about one more width, and the first project
would read the pool down to zero. The share converges there in a few
bounded steps; anything still short after the pool is spent stays for
the next build. The pass's aggregate read bound moves from
2 * query_limit to 3 * query_limit — still constant in the number of
starved projects, which is what retires the original
len(starved) * query_limit blow-up.

test_compression_heavy_widening_is_one_pass_budget_not_per_project now
pins the new contract: every starved project gets a funded retry and
recovers the conversation behind its window; the pass stays inside the
aggregate bound.
2026-09-24 11:03:47 -03:00
rodrigogs 634f66774b fix(sessions): ration the compression-heavy widening to one pass budget
Greptile P1 on #6659: the retry that follows a binding scoped raw window jumped
straight to the global `query_limit`, and every starved project got it in turn.
`remaining` bounds a project's FIRST scoped query, but the widening was not
bounded by the pass at all, so a build scanned len(starved) * query_limit —
an order of magnitude past PROJECT_ASSIGNED_CLI_SCAN_CEILING on a profile with
ten compression-heavy projects, and growing with the number of them.

The widening now comes out of a single pass-level pool of `query_limit`, charged
the full width of each retry, so the scoped reading of a build is capped at
2 * query_limit however many projects are starved. When the pool cannot widen a
retry past the window that just bound it, the loop stops instead of paying for a
query that cannot deliver.

Completeness is deliberately untouched: only the WIDENING is rationed. Every
starved project still pays its own first scoped query, which is the guarantee
the fixed per-build project cap used to break (greptile P1 on #6659). The
constants comment above PROJECT_ASSIGNED_CLI_SCAN_CEILING carried the same
overclaim ("one small project-scoped query per starved project") and now states
the widening budget.

Verified: 88 tests pass across
tests/test_project_assigned_cli_session_limit.py (75),
tests/test_issue2628_cli_sessions_perf.py and
tests/test_issue4842_cron_projection_perf.py. The new regression test
`test_compression_heavy_widening_is_one_pass_budget_not_per_project` is
parametrized over 1 and 3 compression-heavy starved projects and fails on the
old one-liner by showing three full-width retries (16, 16, 16) where the pool
funds one (16).
2026-09-24 11:03:47 -03:00
Rodrigo Gomes da Silva 7e56aaae07 fix(sessions): re-query a starved project when its own window was binding
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.
2026-09-24 11:03:47 -03:00
Rodrigo Gomes da Silva 6411012e9b test(sessions): pin the refill's cost, not just its guarantee
The existing tests all assert what the window DELIVERS, which the ceiling read
satisfies too -- so the widening could regress back to a 200-row read for a
20-row window without a single failure. Two pins on the cost itself:

* newest 40 of 400 moved from the WebUI: query widths exactly [40, 60] and 60
  rows carried, where the degenerate proportional estimate read the full 200;
* 8 moves clustered at the bottom edge of a 200-conversation window: exactly
  [21, 28] and 28 rows, where the crawling estimate spent all four queries and
  carried 200 rows / 192 of them unassigned.

Both fail on the previous widening, so they are pins rather than guards. The
over-read note on test_sidecar_moves_below_the_window_still_deliver_the_full_window
is corrected the same way the code comment is: the surplus is bounded by the
scan ceiling, not by "a few spare conversations".
2026-09-24 11:03:47 -03:00
Rodrigo Gomes da Silva 94c5a4f917 perf(sessions): stop the refill jumping to its scan ceiling
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.
2026-09-24 11:03:47 -03:00
Rodrigo Gomes da Silva 98d5dfdc53 test(sessions): pin the mixed state.db/sidecar assignment store
The unassigned window has to hold when the two stores are used together and
when they disagree: state.db-assigned rows are already excluded by the SQL
filter, sidecar-only ones are not, and a row assigned in BOTH (to different
projects) must be classified assigned exactly once. state.db wins the
conflict, and either way the row does not spend an unassigned slot.
2026-09-24 11:03:47 -03:00
Rodrigo Gomes da Silva ec809227b4 fix(sessions): re-query the unassigned refill until the window fills
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).
2026-09-24 11:03:47 -03:00
Rodrigo Gomes da Silva 00afdf7fb0 test(sessions): pin the edges of the fair assigned-row draw
Two shapes the merged 200-row bound has to keep honest, neither covered by
the reviewer's own 5-project reproduction:

* 8,000 rows over 40 projects — the draw shrinks each share to 5 instead of
  dropping whole projects, so every chip stays reachable (greptile P1);
* 250 projects — past 200 assigned projects the review's number is the hard
  constraint, so the 200 most recently active projects get one row each.
  Pinned so the degradation is not mistaken for a bug later.

Also renames the 40-project case, whose old name claimed more projects than
slots when 200 // 40 = 5 slots each.
2026-09-24 11:03:47 -03:00
Rodrigo Gomes da Silva 084f70df36 fix(sessions): scope project-id resolution to the session's profile
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.
2026-09-24 11:03:47 -03:00
Rodrigo Gomes da Silva 27230d4480 fix(sessions): stop a WebUI-side move spending an unassigned slot
/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.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva bc80d71177 fix(sessions): bound the merged assigned CLI set at the 200-row cap
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.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva 76be369c54 fix(sessions): bound the merged assigned CLI set by a shared 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.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva b5ccf56c53 fix(sessions): refill starved projects when projection under-delivers
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.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva 01fb394001 test(sessions): pin the refill serving every starved project (greptile P1)
Rewrite test_followup_queries_are_capped_and_serve_the_neediest_first
(the test that pinned the residual 32-project limitation) to assert both
starved projects are served in neediest-first order, and add
test_every_starved_project_is_served_past_the_old_refill_cap: 39 starved
projects with the real constants, all 39 must get their scoped follow-up.
The latter fails with the old starved[:32] slice (7 projects missing).
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva d994deb52d fix(sessions): serve every starved project in the assigned refill pass
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.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva 88f53c2662 test(sessions): pin the parent-recovery walk against the re-widened window
The rebase onto e88aff438 put upstream's subagent-parent recovery walk
(8686528b1, supersedes #7031) and this PR's CANDIDATE_WINDOW_MULTIPLIERS
re-widening loop in the same tail of read_importable_agent_session_rows.
The walk anchors a frozen orchestrator by re-reading the projection of the
candidate window, so it has to see the window that ACTUALLY executed, not
the first pass.

Nothing pinned that. Feeding the walk a stale first-window projection keeps
every test in tests/test_subagent_parent_in_import_window.py green, because
those dbs never trigger the second window.

Add the missing case: two 12-segment compression lineages own the 24 newest
raw rows and collapse to 2 logical conversations, so a limit=3 request
consumes the 8x window and under-delivers; the hot subagent leaf and its
quiet parent are only reachable once the window re-widens. Plus a negative
control that pins the 8x window really does stop at the two lineages, so the
recovery is attributable to the widened window.

Verified with teeth: removing the walk fails the new test and 4 upstream
ones; feeding the walk the stale first window fails the new test alone.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva fb4735fe3b test(sessions): pin the query cost of a saturated assigned window
The starvation fix is only acceptable if its cost is bounded, so assert the cost
directly: one GROUP BY probe plus exactly one project-scoped query per starved
project, each carrying only that project's remaining budget, with the recovered
total under PROJECT_ASSIGNED_CLI_SCAN_CEILING.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva 7a596f9114 fix(sessions): stop a busy project starving quieter ones in the assigned pass
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.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva e19c67adde refactor(sessions): resolve project ownership through _profiles_match
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.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva b90d64d9cf fix(sessions): never let the unassigned refill drop a resolved chip
_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.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva 283bf6a047 fix(sessions): keep the interactive exclusion spelled as a literal
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.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva 05816be3c2 fix(sessions): refill the unassigned window when the mixed pass under-delivers
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.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva 8211acdd84 fix(sessions): give a background row one project chip on every path
`_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.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva bc1ee9d400 fix(sessions): run the assigned recovery pass in the all-profiles view
`?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.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva 7c984f7ab6 test(sessions): pin the per-project budget and the no-project_id schema
Mutation-checked the #6659 suite and found two branches no test held:

* the model recovery pass's per-project accounting. Its query asks for
  PROJECT_ASSIGNED_CLI_LIMIT * len(projects) conversations, so on a
  single-project profile query_limit == the bound and the LIMIT alone
  produces the right answer. Deleting the per-project counter kept all
  21 tests green. Only a skewed multi-project profile (250 in one
  project, 5 in another, scan window 400) exercises it.
* schemas with no project_id column, end to end through the model.
  _state_db_supports_project_ids is asserted directly because dropping
  the probe is behaviour-neutral -- the assigned filter already returns
  nothing for such a schema -- so it is a query-budget guard, and the
  contract worth pinning is the probe's answer plus unchanged output.

Both new tests fail against a tree with the corresponding branch removed.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva a07ea78414 test(sessions): cover the #6659 review findings and the greptile P1
Each behavioral test fails on the pre-fix tree and passes after:

- 1,000 assigned rows arriving as imported sidecars -> 1,000 kept (finding 1)
- 500 assigned rows over two projects -> per-project bound, so >200 total survive
- 3 assigned + 20 unassigned -> 17 unassigned, at both the route cap and the
  model window, including the logical-conversation count after lineage dedup
  (finding 2)
- a deleted and a cross-profile project_id -> 'project-ghost' surfaced instead of
  None, and an old ghost row resurrected as an unreachable chip-only row
  (finding 3)
- a project assignment on a newer empty continuation -> dropped, both at the
  projection level and end-to-end through the sidebar (finding 4)
- twelve ten-segment chains, limit=10 -> 8 conversations (greptile P1)
- 250 assigned in one project -> the newest 200, oldest dropped, never a newer one
- 'assigned'/'unassigned' partition the conversations exactly, including a
  lineage assigned only on its tip, and on a schema with no project_id column
- master's kanban second pass still projects rows (signature regression guard)
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva ac6b5d0b37 fix(sessions): bound project-assigned CLI sessions per project and budget the unassigned window separately
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.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva ff274fc406 perf(sessions): re-widen the raw candidate window when segments consume it
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.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva 9a4e4336ad fix(sessions): keep the lineage project id when the root is the freshest segment
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.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva 9d7374fa25 fix(sessions): keep project-assigned CLI sessions past the recent-session cap
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.
2026-09-24 11:03:46 -03:00
Rodrigo Gomes da Silva 2054587673 fix(sessions): gate the prefix count on a writer marker
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).
2026-09-23 10:46:46 -03:00
Rodrigo Gomes da Silva 41efd6c15d fix(recovery): derive sidecar count from message rows 2026-09-23 06:34:38 -03:00
Rodrigo Gomes da Silva a066c25338 fix(sessions): stop save() from reading and parsing the whole sidecar to count it
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.
2026-09-23 06:34:38 -03:00
Rodrigo Gomes da Silva 7862927ef5 fix(http): keep a bodyless rejection's keep-alive, at every reject site
The mirror image of the sidecar over-close already fixed here: the sibling
reject-before-read sites still armed `arm_connection_close()` unconditionally,
so a request with NO declared body was answered with `Connection: close` and
the client's pipelined follow-up was dropped -- a healthy pooled connection
killed for no framing reason. Reproduced on the wire, pipelined down one
socket against the production handler (`responses seen: 1` at each site):

  POST /api/session/new     off-session auth  -> 401 + Connection: close
  POST /api/session/new     bad Origin        -> 403 + Connection: close
  POST /api/session/new     no CSRF token     -> 403 + Connection: close
  POST /api/csp-report      limiter tripped   -> 204 + Connection: close
  POST /api/health/restart  success           -> 200 + Connection: close
  POST /api/process-complete-ack              -> 410 + Connection: close
  POST /api/upload          no boundary       -> 400 + Connection: close

Each now routes through `arm_connection_close_if_body_pending()` (the helper
the sidecar path already used), so the framing decides in both directions:
body-bearing or unreadable framing still closes, an absent or agreeing-zero
Content-Length keeps keep-alive. `check_auth_or_close()` cannot swap the helper
in directly -- it has to arm before `check_auth()` writes its 401/302 and undo
that on success -- so it gates the arming on `request_declares_body()` instead
and keeps the success-path restore.

`/api/health/restart` is armed once, ahead of the outcome, so all four results
(completed / in_progress / busy / error) follow the framing rather than the
result; the success path was closing on every call the WebUI makes.

Only the boundary check in the upload preflight can reject a bodyless request:
every other raise there (any Transfer-Encoding, an unreadable or conflicting
length, an over-cap length) already declares a body and still closes.

Five tests pinned the unconditional close on stubs that declared no body --
i.e. they pinned the bug. Each is re-grounded on real body-bearing framing
(Content-Length, chunked, and an unreadable length) so the close half stays
pinned, with a bodyless sibling asserting keep-alive. New real-socket
regressions cover all seven sites in both directions.
2026-09-22 12:06:09 +00:00
Rodrigo Gomes da Silva b022a79eef fix(http): reject every Transfer-Encoding, not just the ones we can't name
`identity` was exempt as a coding that "frames nothing", so a request
carrying it read back as body-less however many payload bytes followed.
It frames nothing either: RFC 9112 6.3 says a request whose final
transfer coding is not `chunked` has a body length no recipient can
determine, RFC 7230 removed `identity` from the transfer codings
outright, and `http.server` decodes none of them (`rfile` is the raw
socket). Every present value has to reject-and-close.

Reproduced on 8c326003b, pipelined down one socket:

  GET /api/extensions/probe/sidecar/ping  `Transfer-Encoding: identity`
                                          {"stale": true}
    -> HTTP/1.1 403 Forbidden        (no Connection: close)
    -> HTTP/1.1 400 Bad request syntax
           ('{"stale": true}GET /api/health/agent HTTP/1.1')
  POST /api/upload  (and /upload/extract, /workspace/upload, /transcribe)
    -> HTTP/1.1 400 Bad Request     ("No file field in request", no close)
    -> HTTP/1.1 400 Bad request syntax ('--x')

Deletes the `_UNFRAMED_TRANSFER_CODINGS` allowlist and the fold-safety
test that existed only to guard its membership check; `_BLANK_TRANSFER_CODING`
stays, since a present-but-empty header still needs a named, non-falsy
report. The identity rows of the framing tables and of
`test_bodyless_framing_keeps_the_pooled_socket_alive` move to the
closes-the-socket side, with real-socket coverage on the sidecar path and
all four upload endpoints. The keep-alive half stays pinned by the cases
that genuinely have no body: no framing header at all, `Content-Length: 0`,
an OWS-padded zero, two agreeing zeroes.

Swept the rest of the coding surface for the same class: every case
spelling, OWS and non-OWS padding, `identity, chunked`, `chunked, identity`,
`identity,identity`, a lone comma, an unknown coding, duplicated headers,
and a coding beside both a real and a zero Content-Length.
2026-09-22 12:06:09 +00:00
Rodrigo Gomes da Silva 01f2446459 fix(http): read a length only when it is 1*DIGIT, not whenever int() copes
`_declared_content_lengths()` parsed with `int()`, which is far looser than RFC
9110's `Content-Length = 1*DIGIT`: it accepts a leading sign, PEP 515
underscores and non-ASCII digits, and `str.strip()` ahead of it also erased
non-ASCII whitespace. Every such spelling of ZERO therefore parsed to an honest
`0`, took the "every declared length agrees on zero" keep-alive branch, and left
the payload in rfile for the server to parse as the next request line -- the
same defect the blank value had, one layer down. Reproduced live at the previous
head, pipelined down one socket:

  GET /api/extensions/probe/sidecar/ping  `Content-Length: +0`  {"stale": true}
    -> HTTP/1.1 403 Forbidden       (no Connection: close)
    -> HTTP/1.1 400 Bad request syntax
           ('{"stale": true}GET /api/health/agent HTTP/1.1')

byte-for-byte the trace greptile filed the P1 for, and identical for `-0`,
`0_0`, `+00`, `0_0_0`, `+0_0`, `\xa00`, `0\xa0` and `\x850`. On /api/upload the
same values slipped `unreadable_content_length()` into `int('0_0')` == 0 and
answered 400 keep-alive then `400 Bad request syntax ('--x')`.

A sign was reported safe last round because it was probed as `+15`, where a
wrong parse closes anyway; only the zero-valued spellings expose it.

The parse is now the grammar: an unsigned run of ASCII digits, or the framing is
unreadable and a body is assumed pending. The `int()` guard stays -- a digit run
longer than `sys.get_int_max_str_digits()` (4300) raises ValueError too, and a
raise on a rejection path becomes a spurious 500.

`Transfer-Encoding` gets the same audit. Trimming is now SP/HTAB only, RFC 9110
OWS and nothing else, because `str.strip()` erased U+00A0/U+0085 there too:
`Transfer-Encoding: \xa0identity` -- a spelling no token grammar allows -- read
back as the `identity` token, so it counted as unframed and poisoned the socket
with the same 403-then-400 trace (411 and close on uploads). Both characters
reach the wire, since a request line decodes as latin-1. Membership in
`_UNFRAMED_TRANSFER_CODINGS` is tested through `str.lower()`, which maps some
non-ASCII characters ONTO ASCII ones (U+212A -> `k`), so the set is now
documented as ASCII-token-only and a test scans every codepoint to prove no
non-ASCII spelling can reach it.

Non-ASCII digits are unreadable by the same rule and pinned at the helper level:
U+0660/U+FF10 cannot cross an HTTP/1.1 header line (latin-1 leaves only the
U+00B2/B3/B9 superscripts, which `int()` already rejects), but the helpers take
any headers mapping and enforce the grammar rather than trusting the transport.

The no-body contract is unchanged and pinned in both directions on the wire and
at the helper: no length header, `0`, `00`, an OWS-padded zero, two AGREEING
zeroes, and `identity` in any ASCII case still keep the pooled connection alive.
2026-09-22 12:06:09 +00:00
Rodrigo Gomes da Silva 79f5b25f5f fix(http): treat a blank framing value as a declared body, not an absent one
`Content-Length:` with nothing after the colon reads back as '' from
`Message.get_all()`, and `_declared_content_lengths()` SKIPPED the empty value.
The result was "no length declared", so `request_declares_body()` reported no
pending body and the sidecar provenance rejection answered 403 with keep-alive
intact -- leaving the payload in rfile for the server to parse as the next
request line. Reproduced live, pipelined down one socket:

  GET /api/extensions/probe/sidecar/ping  `Content-Length:`  {"stale": true}
    -> HTTP/1.1 403 Forbidden       (no Connection: close)
    -> HTTP/1.1 400 Bad request syntax
           ('{"stale": true}GET /api/health/agent HTTP/1.1')

A blank value cannot prove a body's absence, so it now joins `banana` and two
disagreeing lengths as unreadable framing: bytes are assumed pending and the
connection closes. Absence is something only the wire can say, and an absent
header already yields no values at all.

Three adjacent holes of the same class were found by probing and are fixed by
the same rule (each reproduced live with the same bad-syntax 400):

* whitespace-only `Content-Length:   ` -- `Message` collapses it to the same '',
  as it does a duplicated blank or a blank paired with a real length.
* the four multipart upload handlers: a blank length slipped past
  `unreadable_content_length()`, reached `int('' or 0)` == 0, and answered 400
  "No file field in request" with the payload still queued. Now 400 "Invalid
  Content-Length" with the connection closed.
* blank `Transfer-Encoding:` -- '' sat in `_UNFRAMED_TRANSFER_CODINGS` beside
  `identity`, so a header naming no coding read as "nothing to frame". A blank
  coding is undecodable, not unframed; uploads answer 411 and close.

The no-body contract is unchanged and pinned in both directions: no length
header, a padded zero, two AGREEING zeroes and `identity` all still keep the
pooled connection alive.
2026-09-22 12:06:09 +00:00
Rodrigo Gomes da Silva fb7664bc40 fix(http): read framing from every header value, not just the first
Adversarially probing the re-gated head found the same poisoning reachable by
DUPLICATING a framing header instead of omitting one. `Message.get()` returns
only the first occurrence, so `Content-Length: 0` sent ahead of the real length
made the body invisible to every reader: nothing was drained and the real bytes
were parsed as the next request line. Reproduced live on the otherwise-fixed
head, over one pooled socket:

    HTTP/1.1 403 Forbidden                    <- sidecar provenance reject
    HTTP/1.1 400 Bad request syntax ('{"stale": true}GET /api/health/agent ...')

    HTTP/1.1 400 Bad Request                  <- /api/upload reject
    HTTP/1.1 400 Bad request syntax ('--x')

api/helpers.py now reads framing through `_framing_header_values()`, which
returns every value the wire carried. `request_declares_body()` treats
disagreeing or unparseable lengths as a declared body (they cannot prove the
body's absence), and `unsupported_transfer_encoding()` inspects every declared
coding so `identity` cannot mask a `chunked` behind it. Two agreeing zeroes are
still no body -- repetition alone is not a conflict.

New `unreadable_content_length()` gates the multipart preflight: with no honest
byte count there is nothing to drain, so all four handlers refuse with 400
"Invalid Content-Length" before touching rfile, connection armed for close.

13 regressions, each verified red without this commit: the helper predicates on
a real email.message.Message, the production sidecar 403, all four multipart
handlers, and five pipelined real-socket tests proving the follow-up request is
never answered off a poisoned socket.
2026-09-22 12:06:09 +00:00
Rodrigo Gomes da Silva b3a031a19d fix(http): decide reject-before-read closes from the framing, not the method
Two framing gaps the re-gate reproduced on the production handlers, both the
same class: something other than the wire was used to decide whether unread
body bytes are queued.

1. api/routes.py — the sidecar provenance 403 gated its close on
   `read_request_body`, a static per-method flag. That is wrong in both
   directions: a GET carrying `Content-Length`/`Transfer-Encoding` was answered
   with keep-alive and its body then parsed as the next request line
   (`501 Unsupported method ('{}GET')`), while a body-less DELETE/PUT/PATCH had
   a healthy pooled connection closed for nothing.

2. api/upload.py — all four multipart handlers read framing from
   `Content-Length` alone, so a chunked upload (which has none) was treated as
   length zero, matched no parts and was rejected 400 "No file field in
   request" with keep-alive intact. Wire trace before the fix:

       HTTP/1.1 400 Bad Request                  <- the upload rejection
       HTTP/1.1 400 Bad request syntax ('54')    <- our own chunk-size line

New primitive in api/helpers.py: `request_declares_body()` (Content-Length
present and non-zero, or any Transfer-Encoding other than identity; an
unparseable length counts as present, since it cannot prove absence) plus
`arm_connection_close_if_body_pending()` and `unsupported_transfer_encoding()`.

api/upload.py now routes all four handlers through one preflight,
`_read_multipart_or_reject()`, which refuses an undecodable transfer coding
with 411 before touching rfile — http.server never decodes chunked framing, so
that request can only be rejected — and keeps every previous status and message
(413 oversize, 400 invalid Content-Length/boundary) with its close.

Regressions: chunked reject that closes and a body-less reject that does not,
at all four upload handlers and at the sidecar 403, plus two pipelined
real-socket tests against the running server proving the follow-up request is
never answered off a poisoned socket.
2026-09-22 12:06:09 +00:00
Rodrigo Gomes da Silva 304815228a fix(http): don't let a non-iterable header buffer crash end_headers
advertise_connection_close() guarded its two attribute reads but not the
dedup scan over _headers_buffer. A stub whose buffer is truthy and
non-iterable (a bare Mock) raised TypeError from inside end_headers(),
which _handle_write's generic except would swallow into a spurious 500 --
the same failure class as the missing close_connection guard.
2026-09-22 12:06:09 +00:00
Rodrigo Gomes da Silva a93e8ba890 refactor(http): route every reject-before-read through one arming helper
Replaces the inline `handler.close_connection = True` at each reject-before-read
site with api.helpers.arm_connection_close(), the shared mechanism the review
asked for. Behaviour is unchanged at every real handler; the helper adds the
same stub tolerance the read side just got, so a handler that refuses the
attribute can never turn a 4xx into a 500.

Sites: _check_csrf (origin + token), the sidecar provenance 403 (body-reading
calls only), the rate-limited CSP report, /api/health/restart, the deprecated
process-complete-ack 410, and all four upload handlers' pre-read
length/boundary rejections.
2026-09-22 12:06:09 +00:00
Rodrigo Gomes da Silva 9a181c0026 fix(http): emit Connection: close exactly once and never crash on stubs
The first cut of the close advertisement had two defects, both caught by CI
(9 red jobs) and by the deep-review gate's own MUST-FIX 1 wording.

1. Dedup was keyed off a private `_close_advertised` flag that end_headers()
   set itself. Every SSE endpoint already sends its own `Connection: close`,
   and BaseHTTPRequestHandler.send_header flips close_connection when it does
   — so end_headers() appended a SECOND copy to every stream response.
   Dedup now derives from the pending header buffer, which sees those
   site-level headers too.

2. `self.close_connection` was read unguarded. The attribute only exists once
   a request line has been parsed, so the many suite handlers built with
   `Handler.__new__(Handler)` raised AttributeError instead of emitting
   headers — and in _handle_write that AttributeError became a spurious 500.
   Both reads are now defensive, and check_auth_or_close() no longer invents
   the attribute on a handler that never had it (which would have left a bogus
   keep-alive verdict behind).

The mechanism moves to api/helpers.advertise_connection_close(), which also
puts server.py back under the original 750-line guard, so the test's ceiling
bump to 760 is reverted rather than shipped.
2026-09-22 12:06:09 +00:00
Rodrigo Gomes da Silva 3c5569a30e fix(http): advertise Connection: close on every reject-before-read path
Deep-review gate (2026-08-14) findings, all reproduced:

1. close_connection was never serialized: BaseHTTPRequestHandler does not
   emit Connection: close from the flag, so pooled clients kept reusing the
   socket and died with BrokenPipeError. Handler.end_headers() now emits
   the header (deduped) whenever close_connection is armed.
2. check_auth_or_close armed the flag too late (after the 401/302 was
   already flushed); it now arms before check_auth() and restores the
   prior flag on success.
3. The sidecar provenance rejection closed body-less GETs too; the close
   is now gated on read_request_body=True.
4. Remaining reject-before-read sites now close-and-advertise through the
   same mechanism: rate-limited CSP reports (204), the deprecated
   /api/process-complete-ack (410), /api/health/restart (all outcomes),
   and the four upload handlers (413 + pre-read Content-Length/boundary
   validation via _reject_before_read).

Tests: pooled-client regression against the production handler (reject
with a body, then follow-up on the same pooled connection succeeds and the
rejection carries Connection: close); unit coverage for the sidecar GET
keep-alive, CSP rate-limit, health/restart and upload oversize paths. The
server.py line guard is bumped to 760 with rationale: the deep-review
requires the emission in Handler.end_headers(), which cannot live outside
server.py.
2026-09-22 12:06:09 +00:00
Rodrigo Gomes da Silva 1bd3c72e17 fix: close connection on extension-sidecar provenance rejection
Same class as the auth/CSRF close-on-reject: the sidecar proxy's
same-origin provenance check runs before read_body(), so an HTTP/1.1
connection reused after a 403 would parse the unread body as the next
request line. Flag close_connection on this path too.

Found by Greptile review of #6658; covered by a new unit test.
2026-09-22 12:06:08 +00:00
Rodrigo Gomes da Silva 6a8011f983 fix: keep server.py under the 750-line guard after auth-reject close
Move the close-on-reject decision into api.auth.check_auth_or_close so the
added lines land outside server.py, restoring it to 749 lines. Trim the
inline comment to a one-line docstring on the helper, per review feedback.

The rejected-write connection-close test now patches api.auth.check_auth
(the dependency the helper calls) instead of server.check_auth.
2026-09-22 12:06:08 +00:00
Rodrigo Gomes da Silva 20f6a68626 fix: close rejected HTTP writes before reuse 2026-09-22 12:06:08 +00:00
Rodrigo Gomes da Silvaandrodrigogs 66423ad2dc fix(sessions): stop one oversized metadata field from re-parsing the whole sidecar (#7573)
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>
2026-09-15 21:25:27 -07:00
Rodrigo Gomes da Silva 70c77095a7 fix(a11y): clear stale aria-expanded when switching panels (#6656)
_syncSidebarAria set aria-expanded on the newly-active rail button without
unsetting the previous one, so chat -> kanban -> skills left three buttons all
claiming aria-expanded=true. A screen reader announced three open disclosures
when only one panel owns the sidebar.

Reset every rail button that carries the attribute before setting the active
one.
2026-08-09 21:44:58 +00:00