Commit Graph

298 Commits

Author SHA1 Message Date
Teknium 541fad3c3c test(cron): reconcile summarizer tests with honest chain wording and composed no_agent gate
The cherry-picked tests predate #85508's honest fallback-chain phrasing
and each other: assertions pinned the old 'exhausted or unavailable'
literal and #83188's no_agent fallback-note behavior, which #77648's
mode gate supersedes (no provider classification at all for no_agent
jobs). Assert the composed contract instead.
2026-08-13 11:49:24 -07:00
cation98 efbbc993f6 fix(cron): avoid false provider failure summaries 2026-08-13 11:49:24 -07:00
Jody Bagdonas 7d039d7ee6 fix(cron): classify script timeouts separately 2026-08-13 11:49:24 -07:00
Yanir-R d1fc20432f fix(cron): don't attribute no_agent script failures to a provider
`_summarize_cron_failure_for_delivery` classifies a failed job by
substring-matching the error prose — "timed out", "429",
`authenticat|authoriz` — and maps any hit onto a provider-shaped
explanation, without consulting the job's execution mode.

A `no_agent` job IS its script: `run_job` short-circuits it before any
model is reached. Provider timeouts, rate limits, auth errors and
fallback chains are therefore structurally impossible for it, yet those
branches are tested first.

`_run_job_script` reports a timeout as "Script timed out after {n}s:
{path}". That contains "timed out", so a shell script exceeding its
timeout is delivered to chat as:

  ⚠️ Cron 'x' failed: provider timeout. Fallback chain was exhausted
    or unavailable.

for a job that never opened a socket, sending the reader to inspect
model routing while the actual fault is a shell script. "429" or
"authentication" appearing anywhere in a script's output misfires the
same way.

Gate the three provider branches on `not job.get("no_agent")` and let
script jobs fall through to the existing generic cleaner, which already
reports the real error and names the script. No new message text.

The auth branch carries a word-boundary guard so "oauth" and "4015" do
not trip it, which addresses one substring false-positive; gating on
mode removes the remaining class for script jobs.

Tests: the summarizer had no direct coverage — the only test referencing
it patches it out and asserts on its arguments. Adds parametrized cases
pinning both directions: script jobs are never blamed on a provider
(including when their output contains "429" or "authentication"), and
agent-mode jobs keep the existing provider summaries unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:49:24 -07:00
Teknium 9f2fb838e2 fix(cron): classify TERMINAL_CWD lock timeouts; scrub environment-specific comments
- Widen the scheduler-internal timeout classification to the sibling
  TERMINAL_CWD lock-wait TimeoutError (#79768), which also matched the
  generic 'timed out' branch and was delivered as a provider timeout.
- Reconcile the drift-guard alert with #72056's lifecycle-aware
  remediation: finite one-shots are told to recreate the job, not to
  update a consumed one.
- Scrub environment-specific references from comments/docstrings.
2026-08-13 11:20:27 -07:00
S 05a84f205e fix: clarify one-shot cron drift recovery 2026-08-13 11:20:27 -07:00
Victor Kyriazakos 422c3eaa18 fix(cron): deliver the drift-skip alert untruncated
The generic failure summarizer caps unrecognized errors at 180 chars,
which cut the drift alert off mid-sentence before the pin command. The
drift branch now formats its own delivery from the guard's full message,
so the one alert the operator gets actually contains the fix.
2026-08-13 11:20:27 -07:00
Victor Kyriazakos e6ce8c37f1 fix(cron): drift-guard skips alert once per job, not once per tick
A fleet-wide inference config change previously produced one 'Skipped to
prevent unintended spend' alert per unpinned job per tick — 40 jobs meant
40 alerts every tick until each was re-pinned (Coatue field report,
2026-08-11). The #44585 guard now reuses the #73506 alert-once shape the
preflight path already established: a persisted drift_alerted bit on the
job record, a [drift_skip:silent] marker on repeat ticks that suppresses
delivery, and the bit clears on the next successful run so a future drift
re-alerts. Only the drift branch consults the bit — every other failure
keeps alerting per tick.

The alert text also now says it is sent once, so operators know the job
stays skipped silently until pinned or restored.
2026-08-13 11:20:27 -07:00
Victor Kyriazakos 4282c69120 fix(cron): name the remediation commands in the empty-chain failure alert
A cron that dies on a provider timeout with no fallback chain configured
now tells the operator exactly how to fix it: `hermes fallback add` for a
personal chain, or the cron.model + cron.model_provider fleet defaults for
operator-managed fleets. The exhausted-chain branch stays terse — the chain
is intact there and no config command applies.

Field-reported: users hitting the empty-chain failure could not self-serve
from the alert text alone.
2026-08-13 11:20:27 -07:00
Alexey (CTO) a830c73adb fix(cron): fallback-chain wording reflects whether a chain is configured
_summarize_cron_failure_for_delivery() unconditionally said 'Fallback
chain was exhausted or unavailable.' on every provider failure, even
when fallback_providers is empty (the default -- confirmed empty on
both the root and cto profile config.yaml). That phrasing implies a
fallback was attempted and failed, which sent the operator debugging
the wrong thing.

Add _fallback_chain_phrase(): reads the effective chain via
get_fallback_chain(load_config()) and returns 'No fallback chain
configured.' when it's empty, or the original wording when a chain
exists. Fails open to the original wording on any config read error.

The scheduler's own inactivity-watchdog mislabeling (idle-timeout
reported as provider timeout) was already fixed in a prior commit on
this branch; this closes the second half of t_29b8da55.

Data pull requested by the task (grep errors.log across profiles +
root for 'Provider has been unresponsive' + model=, 2026-07-21 to
2026-08-06): 9 stall events total, 5 on claude-sonnet-5, 4 on
claude-haiku-4-5, spread across 6 different cron jobs. No material
haiku-specific instability -- sonnet-5 stalls at least as often on the
cron path in this sample. Reporting per acceptance criteria; not
worth a routing change on this evidence.
2026-08-13 11:20:27 -07:00
Victor Kyriazakos 58ff0fd302 fix(cron): relay-fronted Slack delivery — synthetic creation-thread capture + preflight fronted-platform blindness
Bug 1: relay-fronted Slack in thread-per-message mode stamps each top-level
message's own id as source.thread_id (session KEYING, native thread_ts
parity). Cron origin capture persisted that stamp as durable routing, so
every delivery landed inside the ephemeral thread spawned around the
creation message instead of the top-level conversation. Fix at the source:
_origin_from_env drops a Slack thread id equal to the creation message's
own id (genuine in-thread creations keep theirs). Fire-time repair for
already-persisted jobs: deliver=origin and the explicit-target Slack
re-attach treat an origin thread as stale when the origin chat is the
configured Slack home chat — top-level (or the home target's configured
thread) wins; non-home working threads are preserved.

Bug 2: _preflight_check_delivery and cron_delivery_targets validated
deliver prefixes against get_connected_platforms(), which only sees
natively configured platforms — a relay-only deployment ({relay}) rejected
'slack:CHAT' with 'no gateway credentials configured' although fire-time
routing (resolve_delivery_transport + RelayAdapter.fronts_platform)
delivers it. New gateway.relay.relay_fronted_platforms() (env-derived from
GATEWAY_RELAY_PLATFORMS — the same source that seeds the live adapter's
identity set, so validation and routing cannot disagree) is unioned into
the connected set when the relay is connected. Native topologies keep the
strict credential check unchanged.
2026-08-13 10:46:13 -07:00
Victor Kyriazakos 8d4b1e4b0e fix(cron): apply create-time origin resolution to the update path too
Review caught a real gap: action='update' also accepts deliver, and the
tool description explicitly steers agents toward update-over-create — so
a cron-context agent updating a job to deliver='origin' would recreate
exactly the dangling literal-origin shape the create-path resolution
prevents (stored 'origin' on an origin-less job → fire-time home-channel
guessing or silent drop).

Wrap the update site in the same resolver. Semantics follow the create
precedent: in cron context, 'origin' means 'my run's target', resolved
concretely at mutation time; outside cron context updates are
byte-identical to before.
2026-08-13 09:42:39 -07:00
Victor Kyriazakos a297edf3ce feat(cron): resolve origin delivery at create time for cron-context job creation
A job created from within a cron run must never store the literal
'origin' delivery target: the creating session is ephemeral, so by fire
time there is no origin to resolve and the scheduler falls back to
guessing a home channel. With agent scheduling enabled
(cron.allow_agent_scheduling), a scheduled agent creating follow-up jobs
would silently produce exactly that dangling shape.

Resolve at create time instead, in cron context only: 'origin' elements
(and an omitted deliver) are replaced with the creating run's concrete
target from the per-run HERMES_CRON_AUTO_DELIVER_* contextvars —
platform:chat_id[:thread_id], or 'local' when the creating run has no
concrete target. Explicit values ('local', 'all', platform:chat_id
targets) pass through verbatim, including inside comma lists. Chat and
CLI creates are byte-identical to before: the resolver is a no-op
outside cron-context sessions (HERMES_CRON_SESSION unset).
2026-08-13 09:42:39 -07:00
Victor Kyriazakos 6e76c2698c feat(cron): config-gated agent scheduling in cron context
Cron-spawned agents have the cronjob toolset unconditionally denied, so
scheduled agents cannot create, tune, or remove jobs even when an
operator wants exactly that (reconciler-style jobs that manage a team's
cron table, follow-up one-shots scheduled from within scheduled work).
The denial is loop-prevention policy, not a security boundary: an agent
with the terminal toolset can already shell out to the CLI, so the
workaround exists but skips every limit and accounting layer.

Add cron.allow_agent_scheduling (config.yaml, default false — byte-exact
current behavior). When enabled, only 'cronjob' leaves the cron-context
denylist; 'messaging' and 'clarify' remain denied as interactivity
constraints, and the user-level agent.disabled_toolsets layering is
unchanged, so a user denylist entry still beats the gate. The cronjob
tool description now states the real policy and the quota bounds instead
of a blanket prohibition.
2026-08-13 09:42:39 -07:00
Ben Barclay 76d832d385
fix(cron): deliver to relay-fronted platforms via canonical home_channel (#84300)
Cron jobs targeting a relay-fronted logical platform (e.g. Discord behind
the relay connector) failed twice over:

1. Target resolution read only the legacy <PLATFORM>_HOME_CHANNEL env
   mirror. The canonical home_channel block that /sethome persists to
   config.yaml — the only store that exists in a relay-fronted deployment,
   where no native env var is exported — was never consulted, so
   deliver='discord' silently resolved to nothing and the job fell back
   to local-only.

2. Even with a resolved target, the delivery loop's native
   configured/enabled gate rejected the platform ('not configured/enabled')
   although resolve_delivery_transport had already produced a live relay
   transport fronting it. A relay-fronted platform is deliberately NOT
   natively enabled (its credential lives in the connector), so the native
   gate must not apply to a relay transport.

Resolution now falls back from the env mirror to
config.get_home_channel(platform) for both chat_id and thread_id (thread
affinity only when the chat id came from the same config block), which
also makes the 'all' routing token pick up relay-fronted platforms. The
delivery gate honours a resolved relay transport, mirroring the
enablement rule resolve_delivery_transport already applied; the standalone
(no-relay) path keeps the historical gate byte-identical.
2026-08-12 14:48:54 +10:00
aameobius b1663edf2a fix(cron): load .env on no_agent path so standalone ticks resolve delivery home channels
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.
2026-08-10 10:58:05 +05:30
ethernet 30da5d0a89 test: run os-specific tests on their real host, not a faked one
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.
2026-08-09 22:09:49 -04:00
Teknium f1c13377a3 test(cron): regression coverage for Windows encoding cluster
- CJK/emoji round-trip + human-readable jobs.json (PRs #52302/#29754)
- emoji through no_agent script stdout capture (issue #42384)
- truncated/invalid UTF-8 script stdout must not raise (#47393)
2026-08-08 12:29:35 -07:00
Teknium 5dc0fa3889 fix: post-merge audit follow-ups for #81138/#81139/#81141/#81148
Four fix-forwards from the adversarial post-merge audit of the Aug 7
unreviewed merge batch:

- estop (#81148): is_engaged() now fails SAFE (engaged) on stat errors;
  the gateway estop gate lets recognized slash commands and replies owned
  by in-flight work (update prompts, clarify, slash-confirm, tool
  approvals, running sessions) through instead of consuming them; new
  gateway /pause [reason|off] command gives messaging-only operators an
  in-band engage/resume path (busy_policy=dispatch so it works mid-run).
- cron monitor mode (#81138): execution-mode invariants (monitor x
  no_agent, monitor_script x monitor_url, no_agent-requires-script) now
  have ONE owner (_validate_job_mode_invariants) called from BOTH
  create_job and update_job, so the create-time invariant can no longer
  be silently violated through the update door.
- cron notepad (#81139): remove_job now clears the job's notepad rows
  (clear_notepad was dead code -> orphaned KV state forever); clear is
  best-effort and no-ops without creating notepad.db.
- delegation batch gate (#81141): template-marker regex narrowed to
  multi-word placeholder shapes only (<feature name>, {file_path}) so
  generics (Vec<T>), HTML tags, JSON snippets, glob braces and f-string
  style no longer reject legitimate batches; duplicate-goal rejection
  removed (best-of-N fan-outs are legitimate).
2026-08-08 05:21:09 -07:00
rjvandeve c7a5de7d6e fix(cron): make pause authoritative against half-paused records
pause_job already sets enabled=false atomically with state/paused_at, but
get_due_jobs only checked enabled — so a contradictory record
(enabled=true + paused_at/state=paused) still fired. That was the 07-30
outage failure mode: list looked frozen, fleet kept merging.

- is_job_runnable / effective_job_state: pause markers gate fire; display
  derives from the scheduler-honoured enabled flag so half-paused never
  renders as [paused]
- get_due_jobs self-heals enabled=false + logs error on contradiction
- claim_job_for_fire uses is_job_runnable (paused_at counts too)
- list/format paths use effective_job_state
- behavioural tests: pause blocks due fire; half-pause self-disables
2026-08-08 13:48:00 +05:30
kshitij 7307f88993 fix: follow-up for salvaged PR #18255
- Fix _usage_audit_path() to use _get_hermes_home() instead of hardcoded
  Path.home() / '.hermes' (profile-safe resolution, sweeper finding)
- Rewrite skip_background_review tests to exercise finalize_turn() directly
  instead of duplicating the guard expression (sweeper finding)
- Fix response_silent audit field to use _is_cron_silence_response()
  instead of the buggy SILENT_MARKER substring check it was meant to
  replace (simplify-code review finding)
- Remove dead 'model' in locals() guard — model is always in scope
  before the try block (simplify-code review finding)
- Extract _stub_agent_for_finalize() helper to eliminate ~40 lines of
  copy-pasted agent stubbing in tests (simplify-code review finding)
- Clean up 'Phase 0.5' instrumentation comments
2026-08-08 00:07:14 +05:30
0xarkstar 15927c1d24 feat(cron): add usage_audit.jsonl logger for cron token leak instrumentation
Phase 0.5 of the Hermes Agent token leak mitigation plan: append a single
JSONL line to ~/.hermes/cron/usage_audit.jsonl after every cron LLM
invocation, capturing prompt/completion/total tokens, model, duration_ms,
deliver target, and error (when raised). Read from agent.session_*_tokens
which run_conversation already returns in its result dict.

Without this, we have no measured baseline to attribute token deltas to
subsequent mitigation phases. The plan's hard gate: observability lands
before any mitigation phase.

Writer NEVER raises — wrapped in a single try/except that logs a warning
on any json.dumps / mkdir / open failure so an audit-log bug cannot
break a cron job. Failure-path audit guard via locals() check covers
exceptions that fire before the fire_id is assigned.

No new dependencies, no new env vars (the plan rejected one in v2).

Tests: 7 new unit tests in tests/cron/test_usage_audit_logger.py covering
the success path, missing token info, swallowed writer exception, parent
dir creation, multiple appends, and unicode preservation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-08 00:07:14 +05:30
Teknium ed903f953e feat(cron): pre-dispatch configuration validation (blocked_config + alert-once)
Validate a job's configuration BEFORE any agent machinery is constructed:

- missing provider API key (AuthError from a read-only
  resolve_runtime_provider probe; skipped when a fallback_providers chain
  is configured, since auth-fallback may rescue the run)
- attached skill not ready (skill_view readiness_status=setup_needed —
  missing required env vars / commands / credential files)
- delivery platform unknown or unconnected (deliver=local/origin/all are
  never checked; gateway-config load failures fail open)

On a failing check run_job returns a [blocked_config]-marked error without
constructing AIAgent/MCP/etc, so a misconfigured job never burns an LLM
call. run_one_job records last_status='blocked_config' and delivers the
alert exactly ONCE across ticks (persisted preflight_alerted bit — the
alert-once shape from the #73506 dead-pin auto-pause); the next healthy
run clears the marker so a future break re-alerts. Every preflight check
fails open: only an affirmative misconfiguration verdict blocks.

Config: cron.preflight (default true); `cron.preflight: false` restores
the old fail-during-run behavior. Documented in the cron user guide and
config defaults.

mark_job_run gains an optional status= override (unblocked call shape
unchanged) and drops preflight_alerted on any successful run.

Tests: tests/cron/test_preflight_config.py (blocked_config + no agent +
single alert across two ticks, healthy job unaffected, recovery clears
dedup, fallback-chain rescue, opt-out restores old behavior, skill
readiness miss, unknown delivery platform, deliver=local never loads
gateway config). Full tests/cron/ + cronjob tool suite green (525 tests).

Ported from: paperclipai/paperclip execution-semantics §5 (MIT);
in-repo precedent: #27948, #73506
2026-08-07 08:57:53 -07:00
Teknium 04e8a661f2 feat(cron): per-job durable notepad — KV scratchpad surviving scheduled runs
- cron/notepad.py: SQLite-backed cron_notepad(job_id, key, value,
  updated_at) store in its own profile-local db (cron/notepad.db),
  following the executions.py connection/transaction pattern. APIs:
  set_note/get_note/delete_note/list_notes/clear_notepad +
  render_notepad_section. Documented size caps: 16KB per value,
  128-char keys, 64KB per job total; oversized writes raise ValueError.
- cron/scheduler.py: inject non-empty notepads into the job prompt at
  the context_from data-injection seam as a clearly-labeled
  "Job notepad (persistent across runs)" section that also documents
  the CLI write path. Empty notepad renders "" — byte-stable prompts
  for jobs that never use the feature.
- hermes_cli/cron.py + hermes_cli/subcommands/cron.py:
  `hermes cron notepad <job_id> [get|set|delete|list]` under the
  existing cron subcommand tree (no new top-level command, no new
  model tool — the agent writes via terminal + CLI).
- tests/cron/test_notepad.py: CRUD, durability, cap enforcement,
  prompt injection, byte-stable empty case, read-failure resilience,
  CLI handler + dispatch (TDD; watched fail first).

Inspired by: Amp (Sourcegraph) cron notepad (idea-level, proprietary —
zero code).
2026-08-07 08:57:48 -07:00
Teknium 6dff2109aa feat(cron): monitor-mode jobs — hash-suppressed change detection
Add monitor-mode cron jobs: a cheap monitor source (monitor_script or
monitor_url) runs on every tick BEFORE any agent machinery is built.
Its output is hashed as exact bytes and compared to the hash stored
from the last agent-triggering tick:

- unchanged  -> agent run suppressed entirely (no LLM, no delivery);
  the tick is recorded as a silent no_change run visible in the
  executions ledger doc
- changed    -> a MONITOR CHANGE DETECTED block (capped unified diff of
  previous vs current output + the new output) is injected into the
  prompt via the existing extra_prompt seam, then a normal agent run
- first run  -> always runs the agent with a baseline block
- source failure -> delivered as an ERROR alert, never treated as a
  change; the stored hash is untouched so recovery to prior output
  still suppresses

Implementation:
- cron/monitor.py (new): hash/diff/URL-fetch/state persistence.
  monitor_script reuses _run_job_script (same ~/.hermes/scripts/
  containment + interpreter rules); monitor_url is a bounded GET
  (30s, 256KB, http/https only). Output is exact bytes by design —
  scripts should emit stable output (documented).
- cron/jobs.py: additive job fields monitor_script / monitor_url /
  monitor_state {last_output_hash, last_changed_at}. JSON job records
  need no migration. create-time validation: sources are mutually
  exclusive and incompatible with no_agent=True.
- cron/scheduler.py: one tight monitor gate in run_job between the
  no_agent short-circuit and the LLM path (outside sibling-lane
  regions). State persists in jobs.json + a per-job snapshot file, so
  suppression survives scheduler restarts.
- tools/cronjob_tools.py: additive optional monitor_script/monitor_url
  params on the cronjob tool (create + update, empty string clears),
  path containment validated at the API boundary, surfaced in
  _format_job.
- hermes_cli: --monitor-script/--monitor-url on `hermes cron create`
  and `hermes cron edit`; `hermes cron list` shows the monitor source
  and last-changed time.

Tests (tests/cron/test_monitor_kind.py, TDD): unchanged suppresses,
changed injects diff, first run always runs, hash persists across
module reload (restart), script failure is error-not-change with hash
untouched, create/update validation, tool wiring + path-escape reject.

Inspired by: ChatGPT Work monitor tasks (idea-level, docs-only);
enabler: #80774
2026-08-07 08:57:44 -07:00
kshitij 30679b876c test(cron): pin fail-closed TERMINAL_CWD lock timeout behavior
- reader/writer run_job timeout paths fail loudly (writer additionally
  proven to never mutate the active holder's TERMINAL_CWD override -
  the fail-open design clobbered it)
- waiter whose holder finishes inside the bound still proceeds
- bound derivation from HERMES_CRON_TIMEOUT (floor, margin, 0/garbage)

The run_job fail-fast test shape follows @necoweb3's #63959.

Co-authored-by: dsad <sswdarius@gmail.com>
2026-08-07 18:11:41 +05:30
dsad 5fcca432f5 test(cron): cover bounded TERMINAL_CWD lock acquisition
Lock-primitive timeout tests from #63959, applied onto the timeout API
that landed via #80912.
2026-08-07 18:11:41 +05:30
kshitij afb46fdab4 refactor(cron): polish registration partial-failure surfaces
Follow-up to the salvaged registration contract:
- share one _raise_if_cron_registration_error() helper for the two
  byte-identical dashboard 424 except-blocks (web_server + cron router,
  via the existing late() seam)
- add endpoint-level 424 coverage for /api/cron/blueprints/instantiate
  (previously only the sync worker was tested)
- give chat/CLI surfaces a human-facing user_message() (job name, no
  exception class name) and add a recovery hint (pause/resume or update
  re-registers via provider reconcile) to the model/REST message
- consolidate five inline provider test doubles into one ABC-subclassing
  make_cron_provider conftest factory; the web_server test double now
  subclasses CronScheduler so an ABC rename fails loudly
- narrow the wrapper facade to keyword-only (**kwargs) and route the
  tool's partial-failure return through tool_error()
2026-08-07 17:45:06 +05:30
Gille f346458f29 fix(cron): surface initial scheduler registration failures 2026-08-07 17:45:06 +05:30
kshitij 261aef5268 perf(cron): stat-stamp fast path for the shrink-merge; no caller-list mutation
Follow-up hardening on the salvaged #80687 shrink-merge guard, folding in
the best part of the competing #80703 (credit: @JoaoMarcos44):

- Stat-stamp fast path: load_jobs() inside a _jobs_lock() section records
  jobs.json's (mtime_ns, size, ino) BEFORE reading; the save-path merge
  and the post-stage verify skip their full read+parse when the stamp
  still matches. The healthy no-race save (every mark_job_run /
  claim_dispatch / heartbeat / advance_next_runs tick persist) now costs
  one stat() instead of up to two full JSON parses.
- Fail-safe stamp discipline: the stamp is captured pre-read (a sibling
  racing the load leaves it older than disk -> mismatch -> merge runs),
  includes st_ino (mkstemp+rename always allocates a new inode, so
  coarse-mtime filesystems cannot false-match), resets on section
  entry/exit, and is INVALIDATED - never refreshed - after any write in
  the section (a refresh would let a nested create_job be clobbered by
  an outer caller's stale payload; probe-verified both directions).
- _merge_unexpected_disk_jobs no longer mutates the caller's list in
  place - it returns a new list when anything was recovered, and logs the
  recovered ids.
- The tolerant read cascade (utf-8-sig + strict=False fallback) is
  factored into one shared _parse_jobs_file used by both load_jobs and
  _peek_jobs_unlocked, so future encoding/shape fixes land once. The
  peek's repair-free re-entrancy contract is now documented - a repairing
  read on the save path would recurse through _save_jobs_unlocked (the
  exact defect the stamp-reconcile approach in #80703 had).

4 new regression tests (fast path, no-mutation, corrupt-file save,
nested-create-vs-stale-outer-save), each verified to fail against the
implementation it guards.

Co-authored-by: joaomarcos <joaomarcosdias444@gmail.com>
2026-08-07 17:29:49 +05:30
HexLab98 5511ec623b test(cron): cover jobs.json shrink-merge against concurrent creates
Regress the #80624 no_agent watchdog clobber: a stale empty save must
not wipe a concurrent create, while intentional remove/replace still work.
2026-08-07 17:29:49 +05:30
kshitij 1e5b507440 fix(cron): move watchdog state under the request lock; fail closed on resolver errors
Follow-up hardening on top of the salvaged #80809 watchdog, porting the
locked state-machine design from #75301 (credit: @Zeraphim):

- All lifecycle transitions (stale/cancelled/done) now happen under
  request_client_lock. A user or monitor interrupt marks the request
  'cancelled' so a racing stale timer can no longer misclassify the kill
  as provider staleness and feed a false +1 into the #58962 cross-turn
  circuit breaker.
- 'done' is set under the lock on completion, so a late timer callback
  that lost the race to a successful response is inert instead of
  leaving a spurious streak=1 behind the reset.
- Registration race closed: if the budget expires while the client is
  still being constructed, _make_client aborts the freshly-registered
  client and fails the call with a retryable TimeoutError instead of
  opening a brand-new socket after the only watchdog already fired.
- _resolve_direct_stale_timeout now fails closed: a raising resolver
  propagates (same as the worker path) instead of being swallowed into
  an infinite budget that would silently disarm the watchdog and
  reinstate the very hang #80759 is about.

4 new regression tests, each verified to fail against the pre-fix
watchdog implementation.

Co-authored-by: Zeraphim <diamantejc87@gmail.com>
2026-08-07 15:32:37 +05:30
HexLab98 cb066a971b fix(cron): bound the inline non-streaming call with a stale watchdog
Cron turns and delegated children are routed onto direct_api_call, which
ran the request inline with no stale detector. The abort plumbing was
registered but nothing ever invoked it, so a provider that accepted the
request and then went silent — connection held open, zero bytes, no
error — hung the run until an external actor killed it, which also
orphaned the execution row. The httpx read timeout is not a usable bound
(1800s default, and this failure mode never trips it), and the job-level
inactivity monitor was observed not to fire.

Arm a watchdog timer on the same budget the interrupt worker's poll loop
uses, so these turns get exactly the patience every other non-streaming
request already gets. On expiry it only aborts the in-flight sockets
through the already-registered hook — it never issues a request, so the
inline / no-worker property that fixes the nested-pool deadlock is
preserved — bumps the cross-turn stale circuit breaker, and surfaces a
retryable TimeoutError so the outer loop reconnects on a fresh pool.

Fixes #80759
2026-08-07 15:32:37 +05:30
liuhao1024 66c60f81b6 fix(cron): thread per-run prompt through cronjob(action='run') (#57331)
Salvaged from PR #57342 by @liuhao1024 (with the injection-scan half
from PR #57360 by @ghedeselmabot): cronjob(action='run', prompt=...)
silently discarded the prompt argument — per-run context never
reached the spawned cron session.

The prompt is now threaded as extra_prompt through the whole chain
(cronjob run action → _try_dispatch_background_run/_execute_job_now →
_run_claimed_job → run_one_job → run_job → _build_job_prompt) and
appended to the stored prompt under a '## Run Context' header for
that single fire only — never persisted to the job definition. It
passes the same strict _scan_cron_prompt injection scan as stored
prompts before firing, and works identically on the background and
sync fallback paths.

Test fakes across tests/cron/ updated to accept the new kwargs
(sibling-test blast radius from the signature change).

Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
2026-08-06 23:14:55 -07:00
liuhao1024 fa96419993 test(cron): add _build_job_prompt extra_prompt regression tests
Pins the scheduler-boundary contract: extra_prompt is appended under
'## Run Context', does not mutate job['prompt'], and the header is
absent when extra_prompt is omitted.

Addresses review feedback from harjothkhara on PR #57342.
2026-08-06 23:14:55 -07:00
kshitij 863e313185 fix: close simplify-pass findings — scheduler sibling site + home-unresolvable totality
3-reviewer simplify pass (reuse/quality/efficiency) findings:

- cron/scheduler.py _run_job_script: the ORIGINAL that
  lifecycle_guard._resolve_script_path documents mirroring had the exact
  same unguarded expanduser() — a NUL-bearing script value survives
  creation (the guard treats it as nothing-to-scan) and crashed the
  scheduler at fire time with ValueError instead of a clean job failure.
  Same ingestion contract applied; regression test added.
- lifecycle_guard._resolve_script_path: get_hermes_home() -> Path.home()
  raises RuntimeError when neither HERMES_HOME nor HOME resolves
  (arbitrary-UID containers); the cron entry point called it bare.
  Caught -> None; totality test added.
- terminal_tool: stale 'cat ...' docstring updated to the bounded
  head -c form.
- lifecycle_guard: dead 'script_text and' condition dropped (guarded by
  'if not script_text: continue' directly above).

Efficiency reviewer: no material findings (measured — encode/expand
costs negligible vs walk I/O, no timing regression vs base).
2026-08-06 17:36:40 +05:30
Paolo Shamoon 03dc4aad52 fix: hide memory tool from cron agents
Cron agents are constructed with skip_memory=True, so the memory
backend is not initialised — exposing the memory tool only gives the
model an unbacked tool that fails at runtime with 'Memory is not
available.'  Add 'memory' to _resolve_cron_disabled_toolsets() so the
tool is stripped from the schema before the model can call it.

Fixes #38129.

Co-authored-by: Paolo Shamoon <Paolo@Dylans-Mac-Studio.local>
2026-08-06 05:20:56 +05:30
kshitij 145e777314 test(cron): close a blind spot in the kanban env drift guard
The AST invariant only matched `env["HERMES_KANBAN_X"] = ...` subscript
assignments, so a future dispatcher var added via `env.update({...})`,
`env.setdefault(...)`, or an annotated subscript would have slipped past
the guard and leaked into cron sessions unnoticed.

None of those shapes exist in _default_spawn today; this is about the
guard staying trustworthy as that function evolves.

Verified by injecting an unregistered var into _default_spawn one shape at
a time and requiring the guard to fail: subscript assign, annotated
assign, update(dict literal), setdefault(literal), and update(kwarg) are
all detected. Source restored byte-identical after probing.
tests/cron/ 410 passed; ruff clean.
2026-08-06 03:16:26 +05:30
kshitij 80f37e36ed fix(cron): don't let a cron job inherit a kanban worker's dispatcher identity
A kanban worker that fires a cron job in-process no longer leaks its task
identity into the cron agent.

The worker is a normal `hermes chat -q` CLI agent whose default toolset
includes `cronjob`, running with HERMES_KANBAN_TASK legitimately set in its
own environment. `cronjob(action="run")` calls run_one_job() -> run_job()
in that same process, so the cron AIAgent was misidentified as that worker:
kanban toolset force-added, kanban-worker protocol injected into its system
prompt, and kanban_complete defaulting task_id to $HERMES_KANBAN_TASK --
letting an unrelated cron job close the worker's task and overwrite real
results.

Fixed with a ContextVar (`non_dispatcher_owned_context`), not by clearing
os.environ. The env is process-global and shared with three concurrent
readers that all need the real values:

  * the worker's own claim heartbeat -- run_agent._touch_activity ->
    heartbeat_current_worker_from_env reads TASK/CLAIM_LOCK/RUN_ID, and the
    cron-run heartbeat thread drives it every 10s. Clearing them silently
    no-ops the heartbeat, so after DEFAULT_CLAIM_TTL_SECONDS (15 min) the
    dispatcher reclaims a task whose worker is still alive and re-dispatches
    it -- the same duplicate-work failure from the other direction.
  * the gateway's kanban watchers, which do their own HERMES_KANBAN_BOARD
    save/restore around a slow decompose_task() LLM call.
  * concurrent cron jobs, which take a *shared* read lock
    (_terminal_cwd_lock.acquire_read) and so interleave: job A clears, job B
    snapshots empty, A restores, B clears and its restore no-ops -- the
    worker's identity is destroyed permanently.

`is_dispatcher_owned_worker_context()` is now the single predicate every
HERMES_KANBAN_* identity gate consults before trusting those vars. It also
closes a pre-existing gap in agent/skill_utils.py, which read the vars
without consulting the delegate_task ContextVar at all; the `kanban` verdict
additionally bypasses _ENV_DETECT_CACHE, since a context-dependent answer
must not be memoized process-wide.

HERMES_KANBAN_BOARD/DB/WORKSPACES_ROOT are left untouched, so the #20074
board pin and the dispatcher's path overrides keep working.

Tests: 18 new, including thread-isolation, concurrent-cron-jobs, and an AST
invariant over _default_spawn that fails if the dispatcher gains a var that
is neither identity-gated nor explicitly classified behaviour-only. All six
mutations are caught, including one that reintroduces the os.environ clear.
tests/cron/ + kanban suites 440 passed; model_tools/skill_utils/boards 63
passed; ruff clean.

Reported and diagnosed by Geoff Friesen (#78961), who identified the symptom
and the exact gating mechanism.

Co-authored-by: Geoff Friesen <gfriesen1@users.noreply.github.com>
2026-08-06 03:16:26 +05:30
Jeffrey Quesnelle 0531aad55d
Merge pull request #68883 from afourniernv/feat/hermes-relay-skill-metrics
feat(observability): aggregate bounded skill metrics
2026-08-05 13:20:57 -04:00
kshitij 1be70d6354 fix: join heartbeat thread in finally + add error-path test
Add activity_hb.join(timeout=2.0) after activity_hb_stop.set() in
direct_api_call's finally block so the heartbeat thread is deterministically
stopped before client teardown. Add test verifying no stray _touch_activity
fires after direct_api_call raises an exception.

Follow-up to PR #78548 by @xxxigm.
2026-08-05 14:00:25 +05:30
xxxigm d55bc063f1 fix(delegation): keep subagents alive during slow model waits
Top-level delegate_task runs in the background, and the 450s progress-stall
monitor only sees api_call_count / tool / last_activity_ts. Subagents use
non-streaming direct_api_call, which previously touched activity once and then
went silent — so a healthy local GGUF / long-prefill wait looked frozen and
was interrupted around ~450s as "Operation interrupted: waiting for model
response", even when child_timeout_seconds was raised. Refresh activity while
the inline request is open, and treat last_activity_ts advances as sync
heartbeat progress too.
2026-08-05 14:00:25 +05:30
Alex Fournier d20debd446 Merge updated tool metrics into skill metrics
Signed-off-by: Alex Fournier <afournier@nvidia.com>
2026-08-04 09:54:43 -07:00
kshitij 0422479031 perf(cron): skip config load on idle scheduler ticks (idea from #33612)
Re-derivation of #33612 by @LeonSGP43 onto the rewritten scheduler (the
original is 10,692 commits behind; its tick() no longer exists in that
shape, so this is a fresh minimal fix crediting the PR's idea).

The gateway's built-in ticker calls tick(verbose=False) every 60s. The
idle early-return was gated on 'verbose and not due_jobs', so idle
GATEWAY ticks fell through to load_config() + worker-pool resolution
every minute. Return early on ANY idle tick; keep the post-tick MCP
orphan sweep (main intentionally reaps orphaned stdio children on idle
ticks).

3 new tests; mutation-checked (restoring the verbose-gated guard fails
the config-skip test). 66 scheduler tests green.
2026-08-03 17:18:51 +05:30
Teknium d1afa16053 fix(cron): retain completed one-shot jobs instead of deleting them on completion
mark_job_run popped a finite one-shot from jobs.json the moment its
repeat limit was reached and returned early — discarding the
last_status / last_error / last_delivery_error it had just written.
Every finished one-shot vanished from `cronjob action=list` with no
inspectable record, and a delivery failure (agent succeeded, platform
send failed) was silently thrown away with it.

Changes:
- mark_job_run now retires a limit-reached one-shot as a terminal
  record (state="completed", enabled=False, next_run_at=None) —
  mirroring the existing next_run_at-is-None terminal branch — so the
  final status and any delivery error persist and surface in the
  cronjob tool's list output (which already emits last_delivery_error
  and defaults to include_disabled=True).
- claim_dispatch's stale-job cleanup marks already-ran jobs completed
  instead of popping them; genuinely wedged claims (last_run_at never
  written) are still removed with the operator-visible diagnostic.
- Retention sweep in the due scan prunes completed one-shot records
  older than cron.completed_retention_days (default 7; non-positive
  disables) so jobs.json cannot grow unboundedly. Recurring jobs and
  non-terminal one-shots are never candidates.

Tests: completion retains record + delivery error, list surfaces it,
completed jobs never re-dispatch, sweep prunes old / keeps recent /
ignores recurring / honors the disable knob; recurring lifecycle
unchanged.
2026-08-02 23:12:00 -07:00
Alex Fournier 884c2daa1c Merge updated tool metrics into skill metrics
Signed-off-by: Alex Fournier <afournier@nvidia.com>

# Conflicts:
#	tests/tools/test_skills_hub.py
2026-08-02 20:12:37 -07:00
kshitij fb6446fc9e fix(cron): scope cron approval context per session
Replace the process-global HERMES_CRON_SESSION env var with a per-session
ContextVar so a cron tick in the gateway process cannot leak into unrelated
live gateway/API/TUI turns. The cron scheduler now sets the ContextVar
inside the job's try/finally scope and resets it on cleanup. Gateway, API
server, ACP adapter, and TUI gateway all pass cron_session='' to explicitly
mark their sessions as non-cron, masking any stale process env.

Co-authored-by: hinablue <hinablue@gmail.com>
Closes #37968
2026-08-03 00:25:20 +05:30
spfcraze 947310437b perf(cron): batch advance_next_run for the due-dispatch loop
The scheduler's pre-dispatch loop called advance_next_run per due job —
one full load_jobs() + one full save_jobs() of the jobs file each — so
N due jobs cost N reads + N writes of the whole file (gateway-restart
catch-up or co-scheduled bursts). advance_next_runs() does one load +
at most one save for the whole due set with identical per-job semantics;
advance_next_run() is now a thin wrapper over it.

Measured (50 due recurring jobs, real jobs file): 107.9 ms -> 2.5 ms
(45x; 50 loads + 50 saves -> 1 + 1).

Tests: batch advances recurring and skips one-shots, single load + save
I/O pin (fails pre-fix — no such function), no save when nothing
advances, and per-job wrapper semantics unchanged. Related: #60946 and
#75833 both restructure this loop's call site for correctness — neither
addresses the I/O cost, and this batch primitive composes with either
dispatch design; happy to rebase onto whichever lands first.
2026-08-02 21:15:16 +05:30
Alex Fournier 3d5fcab70f Merge updated tool metrics into skill metrics
Signed-off-by: Alex Fournier <afournier@nvidia.com>

# Conflicts:
#	hermes_cli/observability/schemas/hermes.shared_metrics.v1.schema.json
#	scripts/smoke_nemo_relay_shared_metrics.py
#	tests/agent/test_skill_commands.py
#	tests/hermes_cli/test_relay_shared_metrics.py
#	tests/hermes_cli/test_relay_shared_metrics_runtime.py
#	tests/tools/test_skill_manager_tool.py
#	tests/tools/test_skill_usage.py
#	tests/tools/test_skills_tool.py
2026-07-31 07:30:30 -07:00
Fangliquan 483b9e3328 test(tests): assert SessionDB timeout without wall-clock
Replace elapsed-time checks with captured Future.result(timeout=...) values so the hang regression stays deterministic under parallel CI load.
2026-07-29 18:55:10 -07:00