Commit Graph

48121 Commits

Author SHA1 Message Date
kshitijk4poor
e8fc41e394 test(email): give the exact authserv-id pin rows a passing verdict
Every topmost row carried dmarc=fail, so it failed on its verdict no
matter what the pin check did: restoring subdomain alignment on the
topmost field kept the test green (gate 2ab Low finding). The subdomain
and comment-smuggled rows now say dmarc=pass, so only the exact pin
rejects them and that mutation goes red.
2026-10-03 20:54:04 +05:30
kshitijk4poor
4fb207491e refactor(email): annotate _message_metadata with email_lib.message.Message
Every sibling signature in the adapter spells the type through the
existing email_lib import; the bare Message import served one annotation
(gate quality Low finding).
2026-10-03 20:54:04 +05:30
kshitijk4poor
c776b6691d docs(email): tell operators with per-message receiver hosts to unset authserv_id
The authserv_id pin now matches the topmost Authentication-Results host
exactly; main also accepted a subdomain of the pin. Operators who pinned a
parent domain for a provider that stamps from changing MX hostnames would
see every message dropped with no guidance (gate 2c Medium finding).
2026-10-03 20:54:04 +05:30
kshitijk4poor
2811f7eaaa docs(email): document topmost-only exact authserv_id match
The adapter now trusts only the topmost Authentication-Results field and
requires a set authserv_id to match it exactly (no subdomain alignment),
so operators whose relay stamps a different top authserv-id see the
untrusted-authserv warning. Say so where the warning is documented.
2026-10-03 20:54:04 +05:30
FroRaut
81706c8b30 fix(email): authorize before fetching MIME bodies
The adapter fetched the full RFC822 payload (and decoded attachments) for
every unseen UID before deciding whether the sender was allowed, and
_verify_sender_authentication searched every Authentication-Results field
for one whose authserv-id matched (or was a subdomain of) the pin. A
forged lower field or an attacker-chosen child.<authserv_id> therefore
authenticated a spoofed From:.

- Only the topmost Authentication-Results field is authoritative; a set
  authserv_id must match it exactly (RFC 8601 CFWS comments allowed via
  _ar_clauses). Main's _NO_AUTH_RESULTS_REASON/_UNTRUSTED_AUTHSERV_REASON
  constants are kept so _DROP_HINTS still resolves.
- _check_inbox fetches a bounded BODY.PEEK[HEADER.FIELDS ...] preflight
  and runs _sender_accepted on the event loop before requesting RFC822;
  rejected UIDs are marked \Seen without downloading the body.

Salvaged from #129772 and rebuilt on main: the PR skipped the preflight
under open access via self._open_access(), which main removed in
c2f595d6af (open access now also authenticates From:), so the gate always
runs here. Poll-loop test mocks now authorize their unauthenticated test
senders instead of forcing open access.
2026-10-03 20:54:04 +05:30
kshitijk4poor
05ab8766e7 refactor(worktree): load the reclaim merge cache only when it is used
reclaim_worktrees loaded the git-cherry merge cache on every call, dry
runs included, though a dry run never revalidates. It also never saved
the verdicts revalidation added, so the next audit re-ran `git cherry`
for any tree whose head moved after the audit (gate r2 efficiency, Low).
Load it only for a real run and save it once after the loop when it
grew, as audit_worktrees already does. Verdicts are unchanged: the cache
is only a memo.
2026-10-03 20:53:08 +05:30
kshitijk4poor
a2ecf83336 fix(worktree): run the attended plain remove under the tree's git env
The attended reclaim's plain `worktree remove` (no --force) reads the
linked tree's index, so it re-hashes stat-dirty files through clean
filters. It ran under noninteractive_repo_git_env(repo_root), which only
discovers filters visible from the main checkout: a filter defined in the
tree's config.worktree (extensions.worktreeConfig) ran its command
(gate r2 2c, Medium). Build the env from the tree itself, as the startup
path already does, keep the tree when discovery fails, and run both the
plain remove and the submodule --force retry under it.

The security test now plants a tree-scoped clean filter on the trees the
startup sweep and the attended reclaim remove.
2026-10-03 20:53:08 +05:30
kshitijk4poor
2a67bb9e8c fix(worktree): name what changed when attended reclaim keeps a tree
Gate finding (Low, quality): the "state changed since audit" message
printed only the fresh classification reason. When the branch or the
untracked list had changed, the verdict was still reap, so the message
read "state changed since audit: clean and fully merged/pushed", which
explains nothing. It now names the field that changed (verdict /
untracked files / branch) and gives the current reason. Also fixes the
stale "`remove --force` refuses" comment: removal is now usually a plain
`worktree remove`. The dead `if not refreshed` guard was dropped in the
previous commit.
2026-10-03 20:53:08 +05:30
kshitijk4poor
4dc2604183 refactor(worktree): share one merge cache and ls-remote across revalidation
Gate findings (Medium efficiency + Low reuse, convergent): the startup
sweep revalidated each reap tree with a fresh _classify_prune_candidates
call. Each call reloaded (and could rewrite) the on-disk merge cache and
built its own ls-remote memo, so every pushed-tier tree paid another
network ls-remote. A 3-tree sweep made 4 loads and 4 ls-remotes; it now
makes 1 of each. The attended reclaim seeded an empty cache and re-ran
`git cherry` for verdicts the audit had just computed.

_classify_prune_candidates takes keyword merge_cache / get_remote_heads.
When the caller supplies them it uses them and leaves the save to the
caller. _prune_stale_worktrees loads once, threads both through
_reap_prune_verdicts, and saves once. reclaim_worktrees seeds from
_load_worktree_merge_cache(). Cache keys include both SHAs, so post-audit
commits still miss. Revalidation still runs per tree, right before
removal. The dead `if not refreshed` guard goes away: a one-item list
always yields one verdict.
2026-10-03 20:53:08 +05:30
kshitijk4poor
f0cacd11e8 fix(worktree): retry the plain remove with --force for clean submodule trees
Gate finding (Medium, 2c + quality convergent): plain `git worktree
remove` always refuses a tree with a checked-out submodule ("working
trees containing submodules cannot be moved or removed"). Once --force
was dropped, clean merged trees like that got a reap verdict on every
startup and every `hermes worktree prune`, and were never removed.

Both paths still try the plain remove first. Only when its stderr names
submodules and an immediate re-check finds the tree still clean
(_worktree_is_dirty False on startup; _dirty_split == (False, []) in the
attended path) do they retry with --force, under the same safe env.
Adds a submodule row to the existing parametrized startup test.
2026-10-03 20:53:08 +05:30
kshitijk4poor
e55816a8a4 test(worktree): fold the include-symlink reclaim case into the startup test
Gate finding (Low, 2c): the stack carried three test functions against a
two-invariant budget. The include-symlink-only tree is one more row of
the startup reclaim invariant (removed when only our scaffolding is
untracked, kept when real work was added), so it becomes a parametrize
case of the existing startup test. It still goes red with 01364366b5
reverted.
2026-10-03 20:53:08 +05:30
kshitijk4poor
5a4b350480 fix(worktree): run the startup plain remove under the repo-safe git env
Gate finding (High, 2ab): dropping --force from the startup reap's
`git worktree remove` makes git read the tree's index, which executes the
repository's core.fsmonitor (GHSA-7x36-8jrh-v4pw) on the unattended
startup sweep. tests/security/test_gitspawn_config_injection.py went red.

Route the remove through noninteractive_repo_git_env(entry) +
stdin=DEVNULL, the same pattern as _worktree_is_dirty; when the env
cannot be built, keep the tree. Extend the security test to also drive
the attended reclaim_worktrees remove (worktree_gc._git already uses the
safe env) so both plain-remove paths are pinned.
2026-10-03 20:53:08 +05:30
kshitijk4poor
62114d180b fix(worktree): drop .worktreeinclude symlinks before plain startup remove
The startup pruner now runs `git worktree remove` without --force so a
tree that gained work after classification is refused. But git treats
our own .worktreeinclude directory symlinks (node_modules, .venv) as
untracked content, so every include-symlink-only tree that the
classifier already reaps was refused and kept forever.

Unlink exactly the paths _include_symlink_paths() recognises (the same
classification _worktree_is_dirty uses) right before the remove; any
other untracked file still blocks the removal.
2026-10-03 20:53:08 +05:30
Rudy Celekli
31194ea00e fix(cli): revalidate worktree cleanup gates before removal
Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com>
(cherry picked from commit 86fad9bd6050a985c9119cf59932ddb5df0fff6a)
2026-10-03 20:53:08 +05:30
kshitijk4poor
37dd129a76 test(telegram): pin per-chat FIFO when a waiting update is cancelled 2026-10-03 20:50:15 +05:30
kshitijk4poor
c03cca2763 refactor(telegram): parse max_concurrent_updates with coerce_port
Gate BR3 r1 reuse (Low): build_update_processor hand-rolled the int-or-
default parse that gateway.platforms._shared.coerce_port already does.
Neither caught OverflowError, so `max_concurrent_updates: .inf` in YAML
aborted connect(). coerce_port now treats non-finite values as unparseable
(as gateway.config._coerce_num already does), which also hardens its other
port/int callers; the telegram setting warns and falls back to 32.
2026-10-03 20:50:15 +05:30
kshitijk4poor
03395f9de7 refactor(telegram): build PerChatUpdateProcessor on SimpleUpdateProcessor
Gate BR3 r1 quality (Low): PTB's SimpleUpdateProcessor already provides the
no-op initialize/shutdown the class copied, and config.extra is always a
dict, so the `(extra or {})` guard was dead. The wiring test only compared
PTB's concurrent_updates property with itself (true for any processor);
assert the PerChatUpdateProcessor type and DEFAULT_MAX_CONCURRENT_UPDATES
instead - now red when a plain SimpleUpdateProcessor(32) is wired in.
2026-10-03 20:50:15 +05:30
kshitijk4poor
ff90457267 fix(telegram): keep per-chat chain intact when a waiting update is cancelled
PerChatUpdateProcessor awaited the predecessor's tail future directly, so
cancelling a waiting same-chat update also cancelled that future (gate
BR3 r1 2c + quality, Medium). The running predecessor then raised
InvalidStateError from done.set_result() after its handler succeeded, and
the next waiter started while the predecessor still ran (probe order:
start A, start C). Shield the await and, for a cancelled waiter, release
its turn only once its predecessor finishes.
2026-10-03 20:50:15 +05:30
kshitijk4poor
74d549e68a test(telegram): teach connect-test doubles about concurrent_updates
connect() now calls builder.concurrent_updates(PerChatUpdateProcessor),
which broke 14 existing connect tests (gate BR3 r1 2ab, High):
- MagicMock builders returned a fresh mock from .concurrent_updates(), so
  .build() no longer returned the test app (7 in test_telegram_conflict,
  1 in test_gateway_platform_event_hook - the unidentified 8th);
- _LifecycleBuilder/_Builder had no such method (6 in polling_progress);
- the gateway conftest's MagicMock telegram stub made BaseUpdateProcessor a
  mock, so the PerChatUpdateProcessor subclass was itself a MagicMock whose
  second instantiation raised StopIteration. Give the stub a real base class.
2026-10-03 20:50:15 +05:30
kshitijk4poor
0b3c03d62e docs(telegram): document max_concurrent_updates and per-chat ordering
Concurrent cross-chat dispatch is now the default; document the knob, its
default, and that same-chat updates stay ordered.
2026-10-03 20:50:15 +05:30
kshitijk4poor
a6d7e08fd1 fix(telegram): keep same-chat updates in order under concurrent dispatch
Plain PTB concurrency (concurrent_updates=32) unblocks other chats while one
chat's handler is slow, but also lets that chat's NEXT update dispatch and
deliver before the first one finishes (download/vision/callback work happens
inline, before text batching can merge them).

PerChatUpdateProcessor (update_admission.py, not the 7k-line adapter) chains
each update behind the previous update of the same chat while keeping the
bounded cross-chat pool. The builder keeps the instance, so the connect-retry
rebuild in _initialize_app_with_retries reuses it. An invalid
max_concurrent_updates now logs a warning instead of silently falling back
(as #74652 did), and the dispatch-stall watchdog comment no longer claims PTB
dispatches sequentially.

Co-authored-by: fortona-space <299084105+fortona-space@users.noreply.github.com>
2026-10-03 20:50:15 +05:30
Yuan Li
7766d4627f test(telegram): regression tests for concurrent update processing
(cherry picked from commit 64cc98fb15e1a4f6643ff4ae4b8aae0b566ee74b)
2026-10-03 20:50:15 +05:30
Yuan Li
5907983b96 fix(telegram): enable concurrent update processing (PTB default serializes all updates)
(cherry picked from commit 443c8b585ae1672e30c66e605b62ba174b3a9162)
2026-10-03 20:50:15 +05:30
kshitijk4poor
54893a83f8 refactor(state): tidy the unique-title repair sort key and user rank
Gate finding BR4 r1 quality (Low cleanups): started_at is REAL NOT NULL, so
`r[3] or 0` in the sort key is dead; the user rank is a class constant, so
read it from _TITLE_SOURCE_RANK instead of the NULL-aware _title_rank helper.
The ranked repair test now closes its raw sqlite3 handle
(contextlib.closing) before SessionDB reopens the file -- `with connect()`
only commits, leaving a Windows file-lock / ResourceWarning hazard.
2026-10-03 20:49:53 +05:30
kshitijk4poor
1ef8e3bd03 fix(state): roll back a failed unique-title repair as a whole
Gate finding BR4 r1 quality (Low): the repair used to be one atomic UPDATE;
it is now a loop of per-row UPDATEs inside a try/except that only logs. A
failure part-way left the rows already renamed/cleared in the open init
transaction, which _init_schema then committed without the unique index --
a half-repaired store persisted without the guarantee.

Wrap the repair in SAVEPOINT title_repair; RELEASE after the CREATE INDEX,
ROLLBACK TO + RELEASE in the except before logging. The ranked repair test
gains a broken-rename row that pins the all-or-nothing outcome.
2026-10-03 20:49:53 +05:30
kshitijk4poor
bcd9c5074d fix(state): number duplicate user titles chronologically in the unique-title repair
Gate finding BR4 r1 2c (Medium): when two user-owned rows shared a title the
repair kept the base title on the newest row and renamed the older one
"Title #2". resolve_session_by_title prefers any "Title #N" continuation, so
`hermes -c Title`, gateway /resume and session_search then opened the OLDER
session (on main they reached the newer one).

The oldest user row now keeps the base title and newer user rows are renamed
in ascending started_at via next_title_in_lineage, so the "#N"-preferring
lookup lands on the newest session again. User still beats llm/derived and
lower-ranked auto titles are still cleared.
2026-10-03 20:49:53 +05:30
Tranquil-Flow
c64617ee8d fix(state): rank unique-title repair by provenance, never drop a user title
The duplicate-title repair that runs before creating idx_sessions_title_unique
kept the highest-rowid row and NULLed every other title, including names the
user typed (and pre-provenance NULL-source rows, which _title_rank treats as
user). A newer session with a lower rowid also lost its title.

Per duplicate title, keep the row with the highest _title_rank, newest
started_at first; clear lower-ranked auto (llm/derived) titles and rename
user-owned duplicates via the existing lineage numbering ("Title #2"), logging
each change. The index is then always created. get_next_title_in_lineage now
delegates to a connection-level helper so the schema init can reuse it.

Fixes #126764.
2026-10-03 20:49:53 +05:30
kshitijk4poor
3ce928bb03 chore: map Tranquil-Flow for salvage of #127743 2026-10-03 20:49:53 +05:30
kshitijk4poor
3d0a61ac94 docs(goal): show the -- <text> escape in the /goal args hint
Gate 2c finding (Low): control verbs with trailing words now win, so goal
text that starts with a control word needs `/goal -- <text>`. The docs
covered it but autocomplete and /help did not; add it to args_hint.
2026-10-03 20:44:07 +05:30
kshitijk4poor
fe8ff9cb21 refactor(goal): one control first-word set for is_goal_control
Gate reuse + quality findings (Low, convergent): is_goal_control checked
_BARE_CONTROL_VERBS plus an inline {"wait", "gate"} literal. Hoist a single
module constant _CONTROL_FIRST_WORDS so the busy-path classifier reads one
verb set next to the dispatcher's. No behaviour change.
2026-10-03 20:44:07 +05:30
kshitijk4poor
bc2a06d209 test(goal): assert the verb actually ran, not just that the goal survived
Gate quality finding (Low): the trailing-words test only checked the goal
text and a conditional hint, so mapping `continue` (or `pause ...`) to
_status would still pass. Each param now names the expected status
(resume/continue on a paused goal -> active, pause -> paused,
status -> active) and every case carries trailing words, so the
ignored-words hint is asserted unconditionally with the exact text.
2026-10-03 20:44:07 +05:30
kshitijk4poor
a56c054b40 test(goal): pin is_goal_control for control verbs with trailing words
Gate 2c finding (Low): the widened is_goal_control (used by the gateway's
busy path to dispatch rather than reject `/goal pause for now` mid-run)
had no test; reverting it stayed green. Assert it per param in the
existing trailing-words test.
2026-10-03 20:44:07 +05:30
kshitijk4poor
7dd083feca fix(goal): treat -- as the escape only when it is its own token
Gate quality finding (Low): `arg.startswith("--")` also matched goal text
that starts with a flag-like word, so `/goal --dry-run the migration`
silently set "dry-run the migration". Match only the standalone `--`
token (the documented `/goal -- <text>` form) and reuse the parsed rest,
so flag-like text is kept literally as on main.
2026-10-03 20:44:07 +05:30
kshitijk4poor
209fbcc47b fix(goal): only announce a replaced goal when one was still live
Gate quality finding (Medium): _set read mgr.state.goal regardless of status,
so `/goal clear` (or a done goal) followed by `/goal <text>` printed
"(replaced the previous goal) was: ..." about a goal that was already gone.
Use mgr.has_goal() (active/paused) to decide what counts as replaced.
2026-10-03 20:44:07 +05:30
kshitijk4poor
b90c5c71cb docs(goal): document the /goal -- <text> escape for control-word goals
Control verbs with trailing words now run the verb instead of replacing
the goal, so goal text that starts with one of those words needs the
`--` escape. Without a docs line the change is only discoverable from the
in-chat hint.
2026-10-03 20:44:07 +05:30
kshitijk4poor
e2e11f49e1 test(goal): assert the ignored-words hint on control verbs with trailing words
The rebuild only checked that the goal text survives; dropping the
'(ignored ...)' hint from _bare_control survived mutation (review on
#127367). Assert the hint appears exactly when trailing words were given.
2026-10-03 20:44:07 +05:30
barnicskolaci
ac1c8de6b9 fix(goal): control verbs with trailing words never replace the goal
`/goal resume last goal`, `/goal continue` on a paused goal and
`/goal pause for now` fell through to the set path and silently
overwrote a standing goal with that sentence. The non-destructive
control verbs (resume/continue/unpause/pause/status/show/unwait) now win
over goal text and report the ignored words; `/goal -- <text>` sets goal
text that really starts with a control word, and a plain replace names
the goal it replaced. clear/stop/done keep their existing behaviour.

Hand rebuild of the goal_command.py part of #127367 (criteria parsing,
/goal history and the clear/stop refusal are left out).

(cherry picked from commit 9a9731437369138bbf4f84362556ae634ab27d6f)
2026-10-03 20:44:07 +05:30
kshitijk4poor
2c7b85fe1c chore: map barnicskolaci for salvage of #127367
(cherry picked from commit c23e97fa5f4dde7a655e5008dc78412fcb77f840)
2026-10-03 20:44:07 +05:30
kshitijk4poor
7533bd2756 refactor(serve): one fenced retirement loop for the skew and owner watchdogs
Gate reuse + quality finding (Low, convergent): start_owner_watchdog's
loop was a line-for-line copy of start_code_skew_watchdog's (confirmation
counter, max_polls bound, fence prepare -> commit -> should_exit, daemon
thread). A change to the fence handshake would have had to land twice.

Move it into web_server_skew_exit._run_retirement_watchdog(server, *,
observe, fence, poll_s, max_polls, thread_name), where observe returns
the retirement log reason while retirement is warranted. Both public
start_* functions keep their signatures and become thin callers with
their own predicate and message. Behaviour is unchanged; the existing
skew and owner tests stay green and both go red if the shared
confirmation count drops to 1.
2026-10-03 19:51:54 +05:30
kshitijk4poor
05b1690ca1 test(serve): pin the owner-watchdog wiring through start_server and serve
Gate 2ab + 2c finding (Low, convergent): both owner-exit tests drive
start_owner_watchdog / should_retire_superseded directly, so dropping the
ssh_lock_path threading in cmd_dashboard or the start_owner_watchdog arm
in _on_server_started left every kept test green and the fix unwired.

Extend two existing tests instead of adding new ones:
- test_web_server_ws_ping's SSH bootstrap test asserts start_server arms
  the watchdog with lock_path/nonce when ssh_lock_path is given, and not
  when it is None.
- test_resource_limits' serve-startup test runs a Desktop SSH serve and
  asserts the lock path handed to start_server is the token's
  desktop-ssh/<ownershipId>/backend.lock.json.
Mutation-checked: removing the web_server arm, the ssh_lock_path pass to
_on_server_started, or main.py's ssh_lock_path each turns one red.
2026-10-03 19:51:54 +05:30
kshitijk4poor
091b783901 refactor(serve): share the lock settle window with the orphan reaper
Gate reuse finding (Low): web_server_owner_exit had its own MIN_AGE_S=90
for the same thing dashboard_procs._REAP_MIN_AGE_SECONDS (180s) already
names: the gap between a serve process starting and the Desktop writing
backend.lock.json. Two constants for one window can drift apart. Use the
reaper's constant (already imported cross-module by web_server, so the
private name is kept rather than renaming every use). This only delays a
lingering superseded backend by up to 90s; it cannot cause a false
retirement. The existing verdict test now pins the shared window.
2026-10-03 19:51:54 +05:30
kshitijk4poor
c044b80d86 refactor(serve): name the Desktop backend lock file once
Gate quality finding (Low): the bare "backend.lock.json" literal now sat
in main.py (the owner watchdog's lock path) and dashboard_procs (the
reaper), both of which must stay in step with the Desktop writer. Add
BACKEND_LOCK_NAME next to _REMOTE_LOCK_SUBDIR and use it in both.
windows_ssh_runtime keeps its own literal (separate Win32 runtime; left
as is, optional per the review).
2026-10-03 19:51:54 +05:30
kshitijk4poor
279be90cf1 fix(serve): bound the backend lock read before the 64 KiB cap
Gate efficiency finding (Low): read_valid_backend_lock read the whole file
and only then rejected anything over 64 KiB. It now also runs on every
15s owner-watchdog poll for the life of an SSH-isolated backend, so an
oversized/garbage backend.lock.json was read in full each time. Read at
most 65537 bytes; >65536 still returns None, smaller files read the same,
and the is_file() pre-check stays (it keeps the read off a FIFO).
2026-10-03 19:51:54 +05:30
kshitijk4poor
cc76781eca refactor(serve): drop provenance from the owner-exit module docstring
Gate quality finding (Low): the web_server_owner_exit docstring carried
contributor credit and prior-art history. Docstrings keep the WHY; the
credit (@VexterZ, #89452) already lives in the fix commit's body.
2026-10-03 19:51:54 +05:30
kshitijk4poor
79f6f944bb fix(serve): retire an SSH-isolated backend once its ownership lock names a newer spawn
A Desktop reconnect that cannot prove the recorded pid is its own (dead-looking
pid, FOREIGN argv proof) drops desktop-ssh/<ownershipId>/backend.lock.json
without signalling that pid and spawns a new nonce. The superseded serve had no
signal that it lost ownership: it lived until the idle watchdog fired (900s
grace, gated on zero WebSockets, which a half-open tunnel keeps non-zero for up
to the 600s ping timeout), so a reconnect storm stacked detached backends, each
an extra state.db writer (#132034).

web_server_owner_exit mirrors web_server_skew_exit: a pure decision (retire only
when a VALID lock names another spawnNonce and the process is past the reaper's
180s settle window; missing, unreadable or skewed locks keep it up) and a daemon
watchdog that needs two consecutive observations, then exits through the
retirement fence so it never tears down mid-turn and admission is closed first.
The lock is read through dashboard_procs' existing validator (split out as
read_valid_backend_lock, shared with _lock_owned_serve_pids) rather than a
second parser, and its path is the validated --ssh-session-token-file's
ownership directory. Server-side only: cleanupStale is untouched, so this
composes with #126035.

Root-cause analysis and watchdog design from the #132034 report by @VexterZ;
lock-ownership watchdog first drafted in #89452 by @pmwanje2 (prior art).

Refs #132034 (POSIX SSH hosts; Windows-host locks are not validated yet)
2026-10-03 19:51:54 +05:30
kshitijk4poor
b2860025ad test(sessions): pin /save json timings intervals to kept rows 2026-10-03 19:21:54 +05:30
kshitijk4poor
3b6589108a fix(sessions): rebuild /save json timings after dropping undone rows
The /undo|rewind filter rewrote only messages; timings had been built from the
unfiltered rows and still named the dropped rows' ids, roles and gaps. One
drop_undone_rows() helper in session_export now filters and rebuilds timings for
both /save paths (import is unaffected: import_sessions ignores timings).
2026-10-03 19:21:54 +05:30
kshitijk4poor
6e0bdb9435 test(sessions): pin the /save json projection by exported row ids
The E4 2c review found the round-trip test stays green when /save json
uses the display projection (include_compacted) instead of the transfer
projection: on the old fixture both hold the same (role, content) pairs.
Once /save drops rewound rows, the two projections differ only on rows the
display read hides, so the fixture gains a model-only live row (a
micro-compaction merge) and the test asserts the snapshot's ids equal every
stored active-or-archived row. The always-include_compacted mutant is now
red on both cli and gateway rows.
2026-10-03 19:21:54 +05:30
kshitijk4poor
b42984652c fix(sessions): keep /undo and rewind rows out of /save json
The E4 2c review found the transfer projection also carries rows /undo,
rewind or edit removed (active=0, compacted=0), and the gateway sends the
/save file into the chat, so a turn the user deliberately took back rode
along in the document. On main /save json never held such a turn. Filter
the /save JSON (CLI and gateway) to active or compaction-archived rows;
import_sessions restores each row's flags, so archived turns still come
back as archived history. `hermes sessions export` keeps full-backup rows.
2026-10-03 19:21:54 +05:30
kshitijk4poor
c5ff25a50a fix(sessions): cap /save json by sessions.max_export_messages
The E4 2c review (convergent with quality) found /save json now loads the
transfer projection -- every stored row, archived included -- with no
sessions.max_export_messages guard, while `hermes sessions export` and the
console export refuse oversized sessions before materializing that same
projection. A heavily compacted session could load its whole archive into
memory from one chat command. Both /save surfaces now call the existing
assert_export_safe() before a JSON export and reply with the shared
SessionExportTooLargeError text; md/html transcripts are unaffected.
2026-10-03 19:21:54 +05:30