Sync upstream hermes-agent @ a0ca7c192 (CONFLICTS) - #335
Draft
hermes-upstream-sync[bot] wants to merge 3367 commits into
Draft
Sync upstream hermes-agent @ a0ca7c192 (CONFLICTS)#335hermes-upstream-sync[bot] wants to merge 3367 commits into
hermes-upstream-sync[bot] wants to merge 3367 commits into
Conversation
The new bot_mode.envelope_ttl_seconds default created a one-field dashboard category, tripping test_no_single_field_categories. Merge it into the agent tab via _CATEGORY_MERGE like code_execution et al.
… fetch blips Review follow-up: relayAgentsOn() returned [] on ANY error, so a transient profiles.list timeout pushed a fresh union roster missing a LIVE machine's agents — and the gateway-side _target_liveness reads 'absent from a fresh roster' as definitively offline, refusing enqueues with a false runtime_offline during the ~60s window. Failure now returns null (distinct from a genuinely empty list); syncRelayRosters reuses the last good rows for that connection and prunes the cache when a connection truly leaves profileRoutes. Source-contract test pins null-on-failure + cache fallback.
feat(desktop): needs-attention badge for background bot failures (#93091 item 3)
feat(bot-mode): envelope TTL + offline fast-fail for bot relay (#93091 item 2)
Cross-connection DMs were pure polling: the Desktop drains every gateway's bot_relay outbox on a 4s interval, so each hop eats up to 4s outbound plus 4s for the reply leg (#92760 'bots reply slowly'). Emission point: the gateway's existing change watcher (_CHANGE_WATCHES in tui_gateway/server.py). Envelopes are written by the AGENT process (message_agent -> tools.bot_relay.enqueue_envelope), not the gateway, so no gateway RPC is on the enqueue path and an in-process emit is impossible. That is exactly the situation the change watcher already solves for the pairing store (pairing.changed: 'written by a different process; the files are the only shared signal') - so a new bot_relay.outbox.pending entry in the existing watch table is the smallest correct diff: one cheap 1s-interval stat probe folded into the existing 0.5s watcher tick, no new thread, no new RPC, and _broadcast_global_event fans it to every connected WS client for free. The signature is monotone (newest envelope mtime ever seen) so a drain emptying outbox/ never re-fires the event. Desktop (hermes-bots plugin): subscribe via the existing host.onEvent tap (feature-detected - older shells lack it) and run drainRelayOutboxes through a 250ms trailing debounce so a burst of signals collapses to one drain. The 4s interval poll is intentionally UNCHANGED as the backstop: the event tap only hears the active gateway socket, so per-connection push detection would be complex and wrong to trade the poll against - push simply makes the common case near-instant while older backends keep working exactly as before. Tests: 3 new watcher contracts (fires on enqueue, monotone across drain, silent with no outbox) and a new relay-push-drain.test.mjs (debounce burst -> one drain, re-arm after window, disposed no-op, poll backstop intact).
… the watermark's fire-again contract Review follow-ups: - A push signal landing while drainRelayOutboxes is mid-flight hit the relayDrainBusy early-return and was gone forever — the gateway signature is monotone (one event per new envelope, never re-broadcast), so the envelope waited out the full 4s poll, exactly the latency the push path removes. relayDrainRerun remembers the race and schedules one debounced follow-up pass after the drain finishes. - test_new_envelope_after_drain_fires_pending_again pins the untested half of the monotone contract: the watermark must not eat genuinely NEW envelopes (write -> drain -> write-newer fires twice). Mutation-checked: a stale-signature regression fails it while the other three still pass.
… on relay stop Final-diff pass: pin both envelope mtimes via os.utime relative to the watermark (write_text alone is wall-clock/FS dependent), and reset relayDrainRerun in stopBotRelay so a rerun remembered mid-drain can't leak one stale drain into the next start/stop cycle.
feat(bot-mode): push-notified relay drain with poll backstop (#93091)
…nstead of racing (#93091)
… agent config tab Two CI failures: (1) the target_busy test's global subprocess.run patch recorded unrelated gateway-init git calls (rev-parse/ls-remote) as the delivery spawn — now local_delivery_command is monkeypatched to a sentinel argv so only the real delivery path counts; (2) the new bot_mode config section is single-field, tripping the dashboard no-single-field-categories rule — folded into the agent tab via _CATEGORY_MERGE (same as #93102's fix).
…entical replies (#93127)
…section Rebase onto main (post-#93102) left two bot_mode dicts in config_defaults.py — later duplicate key silently wins in a Python literal, so envelope_ttl_seconds would have shadowed turn_wait_seconds' section. Single section now carries both keys.
…t resume (#93129) A user 'stop @member' was just log text: the next room delta (receipt round completing, any later turn) re-dispatched the member and it re-claimed the very task it was told to stop. Holds are now durable room state: set by an explicit user stop mention, checked by the round loop before dispatch (skip consumes the delta exactly once — no spin), released only by an explicit resume, @ALL resume, or a direct non-stop mention of the held member. Holds persist and rehydrate with the same durability as room watermarks, and the activity feed shows WHY a held bot is silent (⏸ held glyph + hint) the first time it is skipped. Conservative parse documented in-code: any standalone stop/halt/pause next to a mention holds — a wrongly-held bot is one mention away from release; a wrongly-running one keeps doing forbidden work.
- Drop the false fairness claim from acquire_turn_lock's docstring (LOCK_NB probe + sleep retry gives no arrival-order guarantee; only the budget is). - logger.debug once when the lock degrades to a no-op on fcntl-less platforms so silent serialization loss stays diagnosable. - Document the real worst-case deliver handler hold (120s lock wait + 600s turn = ~720s) where clients tune their timeouts against it. - Pin non-reentry: local_delivery_command must stay a raw 'hermes -p' argv — wrapping it in --run-delivery would make the child contend with its parent's own flock and fail every relay delivery with target_busy. - De-flake: the cross-profile test's upper-bound wall-time assert tolerates loaded CI runners; the wait-duration message assert matches ~Ns generally.
- Cross-thread supersession no longer discards finished work: an epoch bump from a send in ANOTHER thread doesn't re-drive this thread's members (delta filters are thread-scoped), so dropping the finished reply lost completed work until someone revisited the old thread. shouldCommitMemberTurn now drops only when a newer USER entry landed in the same thread; the caller computes that from the log tail past the pre-turn length. - '@ALL stop' now holds every member — it parsed to everyone:true with no mentions and silently held nobody, the asymmetric twin of the tested '@ALL resume'. classifyGroupHoldDirective gains holdAll; the send path passes the room's member keys for expansion. - Tests pin both: cross-thread commit preserved, @all-stop holds all (mutation-checked: reverting either guard fails its test).
Final-diff pass: trimGroupChatLog drops entries from the FRONT once a room crosses the history cap, so slicing the post-turn log at the pre-turn LENGTH could overshoot after a mid-turn trim, read an empty tail, and silently commit a stale turn — re-opening #93127's double delivery in long-history rooms exactly. Anchor on the last pre-turn entry's id; if the anchor itself was trimmed, every surviving entry is newer, so scanning the whole log stays exact.
feat(bot-mode): per-profile turn lock — concurrent deliveries queue instead of racing (#93091)
fix(desktop): group-room duplicate replies + non-sticky stop (#93127, #93129)
…easons on reopen (#92687)
- Clear the accidental end stamp on resurrection (at the lineage tip): a surviving ws_orphan_reap/agent_close reason made a LATER deliberate archive auto-resurrect on the next lookup — the user could never retire the canonical chat. Test pins the resurrect -> deliberate-archive -> stays-archived cycle. - Judge recoverability at the compression TIP: the registry row of a compressed lineage carries end_reason='compression', so tip-stamped accidents were unrecoverable through the registry row. Lineage test. - Heal the third lookup: the api_server exact-title listing (hermes peer dm resolution) filtered archived rows out via list_sessions_rich and still failed for reap-archived canonical chats. - Single source of truth for the recoverable set: tuple moved to hermes_state_common (mirroring _RESET_END_REASONS_SQL) and interpolated into all three recovery SQL sites — literals cannot drift. - methods_session gate uses BOT_CHAT_TITLE (not a literal) and re-fetches by id after resurrection (title has no DB-level UNIQUE). - Idempotence pinned: two consecutive profiles.list calls both resolve.
fix(bot-mode): resurrect canonical Bot Chat archived by recoverable reasons on reopen (#92687)
Preview tiles were scoped to Sessions, so clicking a link in a bot chat called openPreview but never showed the pane.
Cover the unscoped preview contribution and the tree filter so a Sessions-only Browser pane cannot silently regress.
In the Bots pane the Cronjobs rows were inert. The only interactive controls were the enable switch and the hover-only delete button, so clicking a cronjob to see what it runs, when it runs next, or why it stopped did nothing at all — while the same job on the main Cron page opens a full detail panel. The gateway already ships every one of those facts with `cron.manage list` (schedule, repeat, next/last run, last status, delivery target, model, workdir, prompt preview, and the fire/delivery/pause failures). None of it had a surface in Bot Mode: a job failing every run reads exactly like a healthy paused one. The row title becomes a real button that opens a read-only inspector rendered from the record the pane is already holding — no extra RPC, and no second mutation path beside the row's own switch and delete. The switch and delete button stay siblings of the opener, so a toggle can never be swallowed by the open. The inspector tracks the job by id rather than by object, so the 20s poll keeps an open panel live instead of freezing the snapshot it opened with.
Covers the behavior, not the markup: a row exposes an activation target
that opens THAT job; the opener never contains the switch or the delete
control (a nested interactive element would swallow the toggle and is
invalid markup anyway); detail rows carry only fields the gateway
actually sent, so a job that has never run drops those rows instead of
rendering "undefined"; a paused job reports Paused and promises no next
run; the raw schedule appears only when the humanized label dropped
something; and a failing job explains itself in failure order — the run
that never happened outranks the delivery of a run that did.
`routine-owner.test.mjs` asserted the row's owner routing by matching
`function RoutineRow({ job, owner })` against the plugin source, so it
broke on a parameter addition that changed no behavior. Replaced with
the real invariant it was reaching for: toggling the switch sends
`cron.manage` for the owner that rendered the row and evicts that
owner's cache key — which a signature change cannot fake.
Re-fronting the Bots home tab is a close followed by a re-open, which tears down and rebuilds the entire Bots view. `openBotsHomeWorkspace` took that path on EVERY passive reconcile that found the tab open but not holding its zone's active slot, with nothing bounding the retries. That condition is not always transient. `revealTreePane` returns early for a pane in `$hiddenTreePanes` without ever activating it, `isPaneVisible` is false for a minimized zone, and a pane the tree never adopted has no group to be active in. Pinned in any of those states, every signal that reaches a surface sync — sidebar visibility flips, focus churn, group changes — bought one more full remount, and the view visibly strobed. A passive reconcile now gets one attempt. The reveal has already granted or refused the active slot by the time `openWorkspace` returns, so the budget settles on that answer directly instead of waiting for a visibility notification that is not coming: a computed store stays silent when the value does not change. Retiring the tab starts a fresh budget, and an explicit gesture is never blocked. Giving up keeps the surface rather than closing it — a closed home drops the Bots tab through to the ownerless Sessions composer, which is the hole the home exists to plug.
`hermes curator pin` guarded on is_agent_created (a filesystem-shape check), but the flag only matters when the skill carries the curator-management marker: curated_report() walks marker-carrying skills only, so auto-transitions never consider an unmanaged (pre-marker) skill at all. Pinning one recorded the flag and then printed "will bypass auto-transitions" — an effect that does not exist. Keep the write (the flag becomes meaningful after `hermes curator adopt`) and branch the message on is_curator_managed: unmanaged pins now say the skill is unmanaged and point at adopt. Unpin gets the symmetric wording.
…ontract Combining both PRs for issue #92993: #93149 makes set_pinned() return a bool and _cmd_pin/_cmd_unpin exit 1 on a no-op write; #93002's tests stubbed set_pinned with a None-returning lambda, which the combined _cmd_pin now reads as failure. The stub reports True (write landed) so #93002's messaging assertions exercise the intended success path.
… requests Follow-up hardening on #92977 (issue #92976). The cherry-picked retry wrapped every verb, so an ECONNRESET arriving after the backend had already processed a POST (prompt submitted, session created) would silently double-submit on retry. - Extract the transport policy into electron/api-transport.ts so it is unit-testable without Electron: keep-alive agent pools, transient error classification, and a verb-gated withRetry. - Retry rule: GET/HEAD/OPTIONS retry on any transient transport error; POST/PUT/PATCH/DELETE retry only when the request provably never reached the server (connect-phase failures like ECONNREFUSED / ENOTFOUND, or an error thrown before the body was flushed — requestState.bodySent === false). Ambiguous resets after the body went out surface to the caller; when in doubt, don't retry. - Separate keep-alive pools for JSON calls vs streaming downloads so long downloads can't starve latency-sensitive JSON calls. - Destroy pooled agents on app will-quit. - Tests: shouldRetryRequest truth table, withRetry behavior, plus LIVE transport tests against real misbehaving node HTTP servers: a GET burst where the server resets keep-alive sockets (bare attempt fails, retried succeeds) and a POST whose socket is RST after server-side processing (hit counter stays 1 — no double submit).
…ist/sort-imports)
With terminal.backend: docker and container_persistent: true, every gateway session failed on its first tool call: docker run exited 125 with "invalid spec ... too many colons" and no command could execute. _resolve_container_task_id() returns "session:<key>" whenever a session key is present, and gateway session keys are colon-delimited (session:agent:main:telegram:dm:<chat_id>). DockerEnvironment joined that id into the persistent sandbox path verbatim, so the -v spec became ".../docker/session:agent:main:telegram:dm:<id>/home:/root" — docker splits a spec on ':', read the extra fields as extra mount options, and refused the run. The container label a few lines below already guards this exact value class via _sanitize_label_value(); the bind-mount source did not. Derive the directory name through _sandbox_dir_name() instead. Ids that are already bind-mountable are returned verbatim, so the shared "default" sandbox and RL/benchmark rollouts keep their existing directory and no installed package or /root state moves; only ids that could never have produced a working mount are rewritten. A rewrite carries a digest of the original id, because ':' -> '_' alone is not injective and would otherwise collapse two chats onto one persistent /root.
…ndary Drives the real DockerEnvironment constructor with a Telegram DM session key and asserts every persistent -v spec is a two-field bind whose source holds no colon — the assertion that reproduces exit 125 on the unfixed path. The derivation's own contract is covered separately: ids that already work stay verbatim (no sandbox migration), docker's separator and the path separators never survive, ids differing only in rewritten characters keep distinct directories, the mapping is stable across calls so cross-process container reuse still resolves, pathological keys stay inside the per-component length limit, and "."/".."/empty cannot resolve to the docker sandbox root.
…ingularity overlays Hoist the sandbox-directory sanitizer into tools/environments/base.py as sanitize_task_id_for_path() and route BOTH host-path consumers through it: the docker persistent sandbox (get_sandbox_dir()/docker/<id>) and the singularity persistent overlay (hermes-overlays/overlay-<id>). One helper, one mapping, whole bug class fixed in one place instead of per-backend copies (#92414, #92640, #93044). docker.py keeps _sandbox_dir_name as an alias of the shared helper so the sanitized mapping (safe ids verbatim, digest suffix on rewrite for collision safety) is unchanged for existing sandboxes. Co-authored-by: salch-cred <salch-cred@users.noreply.github.com> Co-authored-by: Parker Fawcett <259203091+Parker-Fawcett@users.noreply.github.com>
Behavior-contract tests for sanitize_task_id_for_path (colon/separator removal, verbatim pass-through for existing safe ids, determinism, collision-freedom incl. the a:b vs a_b digest case, traversal and oversized-id bounds) and for the singularity persistent overlay path (sanitized, verbatim for safe ids, distinct dirs for colon-vs-underscore ids). Co-authored-by: chelsealong <chelsealong@126.com> Co-authored-by: Parker Fawcett <259203091+Parker-Fawcett@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
create_job / update_job / resume_job all reject a one-shot whose run time is
more than ONESHOT_GRACE_SECONDS in the past ("will never fire"), and
_recoverable_oneshot_run_at never recovers such a schedule — but
_get_due_jobs_locked dispatched ANY one-shot whose *persisted* next_run_at was
in the past, even hours later (gateway down past the window, host asleep,
hand-edited jobs.json). A wall-clock one-shot then ran hours late, violating
the "will never fire" contract enforced everywhere else.
- Grace gate: a once-kind job whose next_run_dt is more than
ONESHOT_GRACE_SECONDS in the past is never appended to the due list.
- If no run_claim/fire_claim exists (nothing was ever dispatched), retire the
record with a diagnostic file so it stops being scanned and the miss is
operator-visible.
- If a (possibly stale) claim exists, a run may still be in flight in another
process: skip this scan but KEEP the record so its mark_job_run can land
(avoids re-introducing mid-flight record deletion).
- Manual re-trigger still works: trigger_job sets next_run_at=now (inside
grace) so an explicitly re-run stale one-shot fires.
Tests (tests/cron/test_oneshot_grace_due_scan.py): stale-not-due+retired,
within-grace-still-due, stale+claim-skipped-but-kept, retriggered-is-due, and
recurring-jobs-unaffected.
The hosted-provider misfire catch-up (fire_overdue_jobs) fired any runnable overdue job with no one-shot grace check, so a stored past-due one-shot bypassed ONESHOT_GRACE_SECONDS and executed arbitrarily late after downtime. Sibling site of the due-scan gate from #89571; pins both directions with tests.
macOS Chinese pinyin IME: pressing Enter to confirm a candidate word in the group-chat composer submitted the draft as a message mid-composition. The GroupMentionInput onKeyDown checked only `event.key === 'Enter' && !event.shiftKey` with no IME guard, unlike the core composer which guards isComposing + keyCode 229 (#44135). Add the same guard to the three Enter handlers in the bots plugin: - GroupMentionInput (group composer + reply box) — the reported bug - GroupClarifyCard free-text answer input — same premature-submit - skill-hub search input — same premature-trigger Closes #93528
The SameSite=None change updated the website docs but left two
source-level contracts asserting the opposite:
- base.py: LoginStart.cookie_payload said cookies set there "MUST"
be SameSite=Lax.
- cookies.py: the module docstring said all three cookies are
SameSite=Lax.
Both now describe the actual behaviour: session cookies stay Lax, the
short-lived PKCE cookie is SameSite=None; Secure over HTTPS and Lax
over plain HTTP. A provider author following the old base.py contract
would have had a documented reason to undo the fix.
Also records the forwarded_allow_ips caveat in cookies.py: uvicorn only
honours X-Forwarded-Proto from a peer inside forwarded_allow_ips
(default 127.0.0.1), so a TLS terminator reaching the dashboard from a
non-loopback address (a reverse proxy in its own container) leaves the
request looking like HTTP and the cookies written in their HTTP shape.
Docstrings only; no behaviour change.
Merging main brought in the RFC 8252 native sign-in path for password providers (#75808), added while this PR was open. Its loopback-code branch calls clear_pkce_cookie() without use_https, which is now a required keyword-only argument — so /auth/native/password-login raised TypeError on the success path. This is the same call-site class the PR already fixed at the other three sites: the deletion must mirror the shape the setter emitted for the active origin, or the browser keeps the stale PKCE cookie. Caught by CI running the merge commit against main's newer test_dashboard_auth_native_flow.py suite, which does not exist on the branch. Three tests failed there and pass with this change.
…lvage fix(auth): SameSite=None PKCE cookie over HTTPS + matching clear path
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Conflicts detected. Resolve manually before marking ready.
Files with conflict markers:
Brings in upstream commits up to
a0ca7c192(upstream/main).Generated by
ops/hermes-upstream-sync. Branch protection onmainenforces that the agent cannot self-merge — review and merge manually.