The combined [ -f ]/wc -c probe changes the first shell command each
read issues; update the stale mocks that only answered bare 'wc -c'.
The fifo tool-layer test now accepts the merged stat-guard's
success=False note (a fact, not an error) with the shell sentinel
behind it.
The size probe every read path starts with — `wc -c < path` — opens the
path. On a FIFO with no writer, a socket, or a character device that never
reaches EOF, that read never returns, and read_file/read_file_raw/
read_file_bytes all pass no timeout to _exec. The turn wedges until the
process is killed.
The device blocklist in tools/file_tools.py cannot close this: it matches
literal /dev/* names, so it can only ever cover paths someone thought to
enumerate. A FIFO is a file type and can sit at any path.
Gate the probe behind `[ -f ]`, which stats instead of opening, and report
a path that exists but is not a regular file as such. A missing path keeps
its existing not-found handling.
Two falsy-zero coercions in process_registry (salvaged from PR #60004,
credit @isheng-eqi; the EOF half of that PR landed separately in
893792c99):
- wait(timeout=0): schema says minimum=1 but the handler let 0 fall
through '0 or max_timeout' to the DEFAULT wait instead of rejecting.
- read_log(offset=0): conflated with the offset-unset default, silently
returning the TAIL of the log when the caller asked for the head.
Default is now offset=None; explicit 0 paginates from line one.
A read past EOF returned content '900|' (a phantom line-number prefix
that looks like a real line) and an empty file returned '1|' — both
ambiguous silence: indistinguishable, from inside the model, from a
broken tool, so it re-reads and widens windows. Name the dead end and
its recovery instead: 'offset 900 is beyond the end of the file (412
lines total). Retry with offset <= 412.' / 'File is empty (0 bytes).'
Notes, not errors — a fact about the file is not a failure.
Boundary pinned by test: offset == total_lines still reads (an
off-by-one in a resume hint is a silently corrupted read).
Measured (file-only arm, 3 reps, control vs feature): qwen3.8-max
-18% tokens, -26% tool calls, -17% turns across the two affected
tasks; opus-4.8 flat (within rep noise); accuracy held 1.00.
The dep-version-gate ruleset requires a team review for package
manifests, eslint configs, and workflow files. If the autofix patch
contains one of these files, the bot PR waits for that review and
auto-merge stops. The patch step now excludes them, so a bot PR
never gates itself. The eslint check in typecheck.yml still reports
their lint errors.
The requested trigger fires when GitHub creates the run. A run from a
first-time contributor waits in action_required, and the poller then
polls a run that never starts until its timeout. The in_progress
trigger fires when the run starts, and it also fires on a re-run.
The concurrency group now contains the head repository. Fork PRs
frequently share a branch name, and two PRs must not cancel the
poller of each other.
NFC/NFD, narrow no-break space (U+202F), and curly quotes render
identically in a terminal — a model retyping a visually-correct path
gets 'file not found' and can never discover the byte mismatch on its
own. On not-found, canonicalize the requested name and compare against
directory entries; exactly ONE equivalent spelling reads transparently
with an explanatory note. Zero or several matches (homoglyph twins)
fall through — never guess between collisions.
Also: difflib.SequenceMatcher >=0.8 fallback in _suggest_similar_files
catches near-miss typos (AGENT.md -> AGENTS.md) that substring scoring
misses entirely.
Measured (file-only arm, 3 reps, control=guard-only vs feature):
unicode task qwen3.8-max 31k->16k tok (-48%), turns 6.7->3.7;
opus-4.8 57k->33k tok (-42%), turns 8.3->5.0; accuracy held 1.00.
near-miss: opus mildly better, qwen flat, no regressions.
read_file on a workspace FIFO/socket blocked until the exec timeout —
the existing device guard is name-based (/dev/*, /proc/*) and cannot
see an arbitrary special file. Add _special_file_kind(): one os.stat
on the resolved path, refusing FIFO/socket/char/block devices with a
plain note ('no read was attempted') instead of hanging. Host-visible
filesystems only; regular files, dirs, and missing paths unchanged.
Also adds evals/readtool/: an A/B harness that runs the real AIAgent
against hostile-file fixtures (huge lockfile, one-line bundle, FIFO,
NFD filenames, lying extensions) and measures accuracy, turns, tool
calls, and tokens. Measured for this guard (3 reps, file-only arm):
qwen3.8-max fifo task tokens 122k -> 26k (-79%), turns 9.3 -> 5.0;
opus-4.8 tokens 40k -> 23k; accuracy held 1.00 both arms.
User-visible surface from the #82616 session-continuity campaign:
- sessions.md: 'Repair Stranded Gateway Sessions' (evidence rules,
dry-run-first, why adoption is never automatic) and 'Continuity After
Crashes and Restarts' (atomic identity, self-heal, recency resolution,
reset-boundary fence)
- cli-commands.md: repair-routing row in the hermes sessions table
Docs build verified (en + zh-Hans).
After a partial update (stash restore overwriting providers/base.py with
an older version), the NousProfile singleton was instantiated from a
ProviderProfile class that predates the supports_prompt_cache_key field
(added in f4fb23f3d). Accessing profile.supports_prompt_cache_key raised
AttributeError, crashing every API call with:
'NousProfile' object has no attribute 'supports_prompt_cache_key'
Use getattr(profile, 'supports_prompt_cache_key', False) so a stale
profile degrades to 'no prompt cache key' instead of crashing.
hermes update runs in the PRE-pull Python process. After git pull
updates the source files on disk, sys.modules still holds the OLD
hermes_cli.config and hermes_cli.config_migrations. Function-level
imports return the cached module, so DEFAULT_CONFIG["_config_version"]
is the OLD value and check_config_version() reports (33, 33) —
"up to date" — even though the freshly-pulled code has v34 with a
migration to run.
The personality reset migration (#81946) was silently skipped this
way: display.personality: kawaii stayed active after updates that
should have reset it. Every user who updated from a pre-v34 codebase
to a post-v34 codebase was affected.
Fix: _run_config_check_fresh and _run_migrate_config_fresh call
importlib.reload() on hermes_cli.config_defaults, hermes_cli.config,
and hermes_cli.config_migrations before calling check_config_version
and migrate_config. This forces the modules to be re-read from the
updated source files on disk.
Optional skills merged to main after a user's install was cut were
invisible to 'hermes skills install official/...' until they ran
'hermes update' — the OptionalSkillSource only scanned the local
optional-skills/ checkout.
Now, when an official/<category>/<skill> identifier is not found
locally, OptionalSkillSource resolves it against the live default
branch of NousResearch/hermes-agent: one Trees API call enumerates
optional-skills/*/SKILL.md dirs (cached on disk via the shared index
cache, 1h TTL), then the full skill directory is downloaded byte-exact
(including root-level install scripts, LICENSE, tests/ — files the
generic GitHubSource.fetch path drops). search() and inspect() also
surface remote-only skills so discovery works pre-update too.
Local checkout always wins when present; offline degrades to the old
local-only behavior; traversal and ambiguous bare names are refused;
provenance stays official/builtin.
Review follow-ups on the composite salvage (whole-bug-class sweep):
- session.branch and _persist_branch_seed copied parent history without
display_kind/display_metadata, so a tagged timeline marker (personality
pivot, model switch, auto-continue) re-entered the branched session as a
bare role=user row after a restart — re-planting the phantom-ordinal
class this PR fixes. Both projection dicts now carry the tags; regression
asserts added to both branch tests (mutation-checked: fail without the
fix).
- ui-tui renderer learns display_kind=personality_switch (was falling
through to an opaque user bubble; desktop got the case in commit 1).
- programmatic-integration docs: document the two new 4004 refusals
(boolean ordinal, bare confirm_truncate).
- hermes_state comment: archived rows are searchable only with
include_inactive=True, not by default search — align comment with the
actual FTS filter.
- strip stray trailing blank line in test_tui_gateway_server.py
Two hardening guards extracted from #82766 by @StanleyStetson:
- bool is an int subclass, so a JSON `true` in truncate_before_user_ordinal
coerced via int() to ordinal 1 and aimed a CONFIRMED rewind at the second
user turn — the same silent-loss class as #82756. Reject with 4004.
- confirm_truncate with no truncation target is leaked client rewind state
on an ordinary submit; fail fast with 4004 instead of silently ignoring
the flag, so the corrupted client state is surfaced.
Part of the composite fix for #82756.
Guarding the *aim* of a rewind still leaves every other way of aiming it
wrong terminal. All three reported incidents (#70516, #80763, #82756) ended
at the same write — `replace_messages()` in the `prompt.submit` truncation
path — and all three were unrecoverable for the same reason: the rows are
DELETEd, which also evicts them from the FTS index, so there is no `active=0`
archive and nothing to restore from.
The codebase already draws this distinction and already has the safe half of
it. `archive_and_compact` is documented as "the durability-preserving
alternative to replace_messages"; `rewind_to_message` — the `/undo` path —
soft-deletes to `active=0, compacted=0` and keeps the rows "on disk for audit
/ forensic inspection". The desktop rewind is the same user-facing operation
as `/undo` and was the one taking the destructive branch.
`replace_messages(..., archive_dropped=True)` flips the DELETE to a
content-preserving `UPDATE messages SET active = 0`, reusing the existing
transaction and the existing `active=0, compacted=0` marking so the dropped
turns stay readable via `get_messages(..., include_inactive=True)` and stay
out of session search (`compacted=0` = "the user took it back", vs
compaction's `compacted=1` = "summarized away, still discoverable").
The live transcript is byte-identical either way — only the durability of the
dropped turns changes. The parameter defaults to False, so the fork handler,
the ACP adapter and `gateway/session.py` keep their current semantics
untouched; a test pins that.
`active_only=True` stays on the call: #80216 still applies, and archiving must
not disturb rows an earlier compaction deliberately archived.
Test doubles for `replace_messages` in the gateway suite are widened to the
real signature — they are stand-ins for SessionDB, and a double that does not
accept what production passes silently converts this write into a 5008.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`truncate_before_user_ordinal` is an index into the list of *real* user
turns. The gateway builds that list with `role == "user" and not
display_kind`, and `test_prompt_submit_truncate_ordinal_skips_display_kind_rows`
already pins why: "Without the filter, a trailing marker shifts the ordinal
so the wrong message is targeted for truncation."
`_apply_personality_to_session` broke that invariant at the producer. Its
pivot marker rides as `role=user` — deliberately, so strict
OpenAI-compatible providers accept it mid-conversation (the same reason
`_append_model_switch_marker` does) — but unlike the model-switch marker it
carried no `display_kind`. The gateway therefore counted it as a real user
turn while no client ever renders it as one.
After a personality change the two sides address different lists: every
later rewind/edit/regenerate resolves one slot too early, and
`replace_messages()` hard-DELETEs the extra span. That is the reported
signature — an in-range, valid ordinal, `confirm_truncate: true`, and a cut
that moved backwards with no user rewind action.
Tag the pivot like the model-switch marker, and teach the desktop to
project the kind as a timeline row so a persisted marker is never rendered
— or counted — as a user turn on the client side either. Both ends must
exclude it; excluding it on only one end just inverts the drift.
The regression test drives the real injection point rather than a
hand-written marker dict. Without the fix it fails with "the pivot shifted
the ordinal: the cut landed at 3 instead of 5", losing a turn the user
never asked to drop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The result-error path in _deliver_result is not inside an except block,
so sys.exc_info() always returns (None, None, None) — the condition was
always False. Simplify to a plain logger.error call with accurate comment.
hermes-cron-tick.service starts without TELEGRAM_HOME_CHANNEL/DISCORD_HOME_CHANNEL
in the unit env; the per-run load_hermes_dotenv reload lived only on the agent
path (after the no_agent short-circuit returns), so every deliver=telegram/all
script job failed with 'no delivery target resolved'. Load the dotenv at the top
of the no_agent branch; override=False keeps the gateway's in-process tick
behavior unchanged.
_normalize_bundle_path rejected absolute paths, .. traversal, and a bare
drive-letter prefix, but permitted a colon inside a later path component.
On NTFS a bundle member named scripts/helper.py:payload writes a hidden
Alternate Data Stream into the visible file scripts/helper.py. The skill
scanner walks with rglob('*'), which does not enumerate streams, so both
operator review and the guard scanner miss the executable bytes.
Reject a colon in any component (the whole class, not just the trailing
one). This subsumes the previous bare drive-letter check, which is folded
into the single colon guard. '/' is the only legal separator once
normalized, so no portable bundle path needs a colon.
Adds an OS-independent quarantine_bundle regression plus a direct
normalizer unit test covering leading/mid/trailing-component colons,
bare/qualified drive letters, and the empty stream name.
Reported-by: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com>
Trim verbose comments in conversation_loop.py and run_agent.py to 2 lines
each. Fix the same bug class in the compression summary path at
chat_completion_helpers.py: remove _thinking_prefill from the explicit
pop tuple and move the generic underscore-key sweep to after
_drop_thinking_only_and_merge_users, so the drop pass can recognize
prefill stubs there too.
- warn (not debug) on final text-turn flush failure: a failure here
reopens the exact #81641 data-loss window with _persist_session as
the only remaining retry, unlike the verify siblings which retry
in-loop; include session id for triage
- trim the flush-site comment to sibling proportion, pointing to the
test module for the full incident narrative
- test: assert _persist_session presence before indexing, so a wiring
change fails with a clean assertion instead of ValueError from max()
A pure-text assistant turn (finish_reason=stop) had no durable write of
its own. Its answer reached the user through the streaming / interim
display path, which is display-only and never touches state.db, and the
first durable write was finalize_turn's _persist_session — after the
loop exits and behind post-turn work that can include micro-compaction's
aux-LLM call.
Anything that ended the process or tore the session down inside that
window lost a reply the user had already been shown. On a remote
(non-loopback) backend the window is easy to hit: WS 1006 closures drive
ws_orphan_reap teardown, and affected sessions ended up with user rows
and zero assistant rows in state.db.
The neighbouring exits of the same loop already close this gap:
* the tool-call exit flushes the assistant(tool_calls) block before
handing control to _execute_tool_calls (#49045)
* the verify-on-stop and pre_verify exits flush final_msg before
appending their nudge (#65919 §7)
Apply that same idiom to the ordinary text exit rather than adding a new
persistence mechanism. The intrinsic _DB_PERSISTED_MARKER dedup makes the
later _persist_session a no-op for this row, so no duplicate rows and no
extra write — the same write, just earlier.
Unlike the tool-call exit, a failed flush must not abort the turn: no
side effect runs after this point and the answer is already produced, so
the failure is logged and _persist_session remains the retry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The live comment poller inferred completion from the job list. An empty
job list looks the same as a finished run: GitHub has not spawned the
jobs yet, so nothing is pending, and the poller posted a final
"all good!" comment and exited.
The run status is now the authoritative signal. collect_run_jobs()
returns whether the CI run and every watched sibling run report
status=completed, and the loop exits only when no job is pending AND
all runs are complete. While a run is still queued or in progress with
no visible jobs, the comment shows "waiting for jobs to start" instead
of a final banner.
Six test files still selected an OS branch with a faked host. Each one now
carries the marker for the host that owns the branch, or derives the
expectation from the real host:
- test_clipboard: macos_only on the has_clipboard_image dispatch. The fake
picked the branch, but _macos_has_image needs osascript.
- test_claw: windows_only on the tasklist/powershell scan, with return_value
in place of a side_effect list that pinned the call count.
- test_linux_desktop_entry: the parametrize over "darwin"/"win32" becomes one
marked test per host. A fake left POSIX paths and a POSIX XDG layout.
- test_graphical_browser_detection: linux_only on the display-server arm. The
$BROWSER check runs before the platform branch, so its test stays unmarked.
- test_auth_nous_provider: the fixture pinned linux so the macOS certifi
fallback could not change the result. The assertion now reads the host, so
the macOS lane covers the fallback too.
- test_tts_macos_output and test_voice_mode: the afplay policy exists because
CoreAudio init raises a TCC prompt, which no Linux runner reproduces.
tests/conftest.py refuses collection when one test carries two OS markers.
Each marker skips on all but one host, so two of them make a test that runs
nowhere while every lane reports green. tests/test_os_marker_gating.py pins
that behavior.
The docstring on TestConfirmDestructiveSlash said the Windows job runs it.
The class has no marker, so -m windows_only deselects it.
`shell: bash` runs the step with -e injected, and `set -uo pipefail` does
not clear it. A non-zero pytest exit killed the script before `status=$?`,
so the -eq 5 branch and its ::error message never ran. The job still failed
red, but the diagnostic that names the cause never printed.
the markers from the previous commit skip off-host. without a host to
run them on, every marked test is a silent skip. this commit adds the
hosts.
- tests-os.yml runs -m macos_only on macos-latest and -m windows_only
on windows-latest. ci.yml requires both lanes in all-checks-pass.
- a lane fails on pytest exit code 5 (zero tests selected). a renamed
marker cannot produce a green job that ran nothing.
- each lane repeats 'not integration' because a command-line -m
replaces the addopts filter.
- scripts/ci/list_os_marked_tests.py selects which files each lane
imports. -m filters after collection, and collection imports every
module. without this helper, one unrelated ImportError on the
foreign host fails a job whose own tests passed. the helper exits
non-zero when a marker matches no file, and writes bytes with
explicit lf so windows crlf translation cannot corrupt the bash
file list. it has its own tests in tests/ci/.
- the local runner now reports the skipped count and prints a note:
macos_only/windows_only tests were skipped on this host, and this
ci lane runs them. a green local run on linux no longer reads as
coverage of the other hosts.
- the runner default job count is now #cpu, not #cpu*2.
many tests patched sys.platform or a module's _IS_WINDOWS flag, then
ran on linux ci. the patch selects the branch under test, but the host
does not have the behavior the branch exists for. the test proves the
patch, not the platform. some gated assertions never ran on any host.
this commit adds three markers: linux_only, macos_only, windows_only.
a conftest hook skips a marked test on the other hosts, with a clear
reason. no test fakes a host now. two documented fakes remain
(android/termux, freebsd) because no ci runner exists for them.
each fake site got one of four treatments:
- gate it: the real host supplies the platform; mocks cover real
dependencies only, never host identity
- patch the module's own probe when the subject is the probe's consumer
- assert against the real host when the fake stood in for any non-x host
- delete the patch when it set the value the host already has
bare skipif(sys.platform != ...) guards became markers too. the lane
model skips these on linux and never imports them on windows, so they
ran on no host. platform parametrize tables are now one marked test
per os.
running on real hosts found real errors: a chrome-sandbox failure in
test_gui_command that main hides, and two windows failures fixed here.
the agents.md testing section now documents the policy.
streaming code blocks in the light theme render near-white text on the
white code card until shiki's highlight lands, then snap to normal token
colors. the pale text is @tailwindcss/typography's pre foreground: its
prose theme styles pre as a dark slab (--tw-prose-pre-code = gray-200 on
a gray-800 bg). we strip the bg for our own code card but the near-white
foreground survives on the container. shiki's opaque per-token span
colors normally hide it — it shows through wherever text renders without
spans: the streaming delay window, the lazy-chunk suspense fallback, and
over-budget blocks that never highlight.
traced on the live renderer: computed color on the wrapper of mid-stream
code was oklch(0.928 0.006 264.531) (gray-200), supplied by the
.prose :where(pre) rule.
fix: prose-pre:text-foreground on the markdown container, so every
fenced path inherits the transcript foreground instead. the utility
layer is emitted after typography's base rule in the built css, so the
override wins by order at equal specificity.
Replaces the canonicalization test (which pinned the behavior #74425
removes) with wrapper-preservation coverage for auto-detection and an
explicit remoteHermesPath, both asserting no python3 -c parser call is
issued. Verified both fail against the pre-fix implementation.
Problem 1: resolveLauncher() read bash 'exec <python> <script>' wrappers
and returned ONLY the python interpreter path, discarding the script.
This made probeHermesVersion() run '<python> --version', which always
printed 'Python x.y.z' instead of the Hermes version. And
remoteSupportsSshOwnership() ran '<python> serve --help' which failed
entirely because no 'serve' module exists in the python stdlib.
Problem 2: When the user set remoteHermesPath (an explicit override),
resolveLauncher() resolved it to the python interpreter, replacing the
user's specified path. The override was effectively ignored for version
checking and capability probing.
Fix: resolveLauncher now returns the candidate path directly. The hermes
binary or wrapper script is already executable and handles argument
forwarding (e.g. 'exec <python> <script> "$@"') correctly on its own.
No additional remote SSH round-trip or python script needed.
Both session recovery paths (the startup stale-entry repoint and the
lazy in-message recovery) rebuilt the routing entry with updated_at=now
and never consulted _should_reset, so an opt-in idle/daily session_reset
policy was silently dead across any gateway restart: a recovered session
always looked freshly active, and since every subsequent message bumps
updated_at, a session recovered stale could then never age out at all.
Fix in three parts:
- _create_entry_from_recovered_row derives updated_at from the durable
last_activity_at the finder already returns on the row (no extra DB
round-trip; the original PR added SessionDB.get_last_activity for
this, unnecessary post-#82633), falling back to created_at. An
invalid or missing started_at now maps to epoch 0 instead of now — an
invalid durable timestamp must look old, never freshly active.
reset_had_activity is set from the row's durable activity/message
signals so the continuity hint stays accurate.
- _recover_session_from_db evaluates _should_reset on the rebuilt entry:
an overdue session is durably promoted to a reset boundary
(promote_to_session_reset, falling back to end_session) and the stale
mapping is dropped instead of repointed.
- _query_recoverable_session no longer reopens the row; the
get_or_create_session recovery phase evaluates _should_reset first and
either feeds the normal auto-reset create path (reset notice,
prev_session_id continuity, durable promotion) or reopens and
publishes the recovered entry exactly as before.
Behavior is unchanged under the default session_reset mode "none":
_should_reset returns None there, so recovery still resumes every
recoverable row — only users who opted into idle/daily resets see the
policy actually applied across restarts.
Cherry-picked from #78618 and adapted to the #82633 finder.
(cherry picked from commit 31c71f7629)
find_latest_gateway_session_for_peer filtered non-recoverable rows out of
candidacy BEFORE ordering, so recovery could search behind a /new reset
boundary and resurrect an older still-open row for the same peer —
silently restoring the exact context the user reset.
Rebuilt against the #82633 finder (has-messages ranking +
COALESCE(last_activity_at, started_at) recency): the fence is expressed
as a NOT EXISTS guard inside both the exact-key and peer-fallback
queries — a candidate is rejected when an intentional boundary row
(session_reset / session_switch / idle / daily / suspended /
resume_pending_expired) for the same peer ended after the candidate's
last activity. If the conversation's most recent event is an intentional
reset, recovery returns nothing rather than reaching behind it.
Cherry-picked from #68617 and adapted to the rewritten finder.
(cherry picked from commit bb2c562a16)
Complete the /branch routing-identity fix (salvaged from PR #62278 by
@jcjc81): in addition to user_id/session_key/chat_id/chat_type/thread_id,
forward origin_json and display_name at create_session() time, matching
the reset-path db_create_kwargs pattern (#82633) so the branch row is
born with full identity — no backfill gap for state.db consumers
(mcp_serve, mirror, channel directory) if a crash lands before
switch_session().
The obsolete compression-rotation half of #62278 was dropped: rotation
now goes exclusively through publish_compression_child, which already
copies all identity columns in-transaction.
The sweeper flagged two gaps in the routing-columns fix:
1. /branch create_session() omitted user_id and session_key — the
fallback lookup path (find_latest_gateway_session_for_peer) requires
user_id to match the complete peer tuple when session_key lookup fails,
and /resume IDOR guards reject sessions without matching user_id.
2. Compression-rotation create_session() omitted agent._user_id — same
problem: rotated child cannot satisfy persisted /resume ownership proof
before the later gateway backfill.
Forward user_id and session_key at CREATE time in both call sites so
the child row is immediately fully routable with zero backfill gap.
Extended tests: compression rotation asserts user_id is carried (and None
for CLI sessions). Branch routing asserts both user_id and session_key on
the child row before switch_session runs.
Same defect as the compression-rotation fix in the prior commit, found
during a full-audit of every create_session() call site per the repo's
'fix the whole bug class, sibling call paths included' contribution
guidance.
_handle_branch_command() (gateway/slash_commands.py) creates the branched
child session via create_session() without chat_id/chat_type/thread_id.
The routing columns are only backfilled later, when switch_session() runs
at the end of the function and calls _record_gateway_session_peer(). In
between, the function copies the parent's conversation history to the new
session_id one message at a time, with each append_message() call
independently try/excepted (best-effort) — a crash/kill anywhere in that
window leaves the branched session permanently unroutable, same failure
mode as the compression bug: NULL chat_id/thread_id can never be found by
find_latest_gateway_session_for_peer, AND unreachable via /resume's IDOR
guard (which requires the row's chat_id/thread_id to match the caller's).
Fix: forward source.chat_id/chat_type/thread_id at create_session() time,
mirroring the existing correct pattern already used by /title's
auto-create path a few hundred lines up in the same file (which has an
explicit IDOR-scoping comment justifying it).
Tests: tests/gateway/test_branch_routing_columns.py drives the real
_handle_branch_command against a real SessionStore + SessionDB (SQLite in
tmp_path, no DB/session-store mocks). Patches switch_session to simulate a
crash landing before it runs (the exact gap the routing columns need to
survive), then asserts the branched child's chat_id/chat_type/thread_id
are already correct in state.db at that point. RED verified against
unpatched code (assert None == '170829464'), GREEN after the fix.
Regression: 102/102 across the new test + pre-existing /branch, session
boundary, compression rotation, DM thread seeding, session API, and
resume-command suites. Broader tests/gateway/ -k "branch or session_api or
resume or topic_mode or session_boundary" sweep: 255/255 passed, 1
(unrelated) skip.
test_authoring_standards.py::test_description_hardline red on main since
461c493972 landed with a 383-char description. The trimmed detail is all
preserved in the SKILL.md body (When-to-use, decision tree, search_files
comparison). Unbreaks every open PR's slice 4.