Commit Graph

3765 Commits

Author SHA1 Message Date
Teknium a1fdf5556e fix(kanban): clarify that watch names its initial board 2026-09-07 06:04:06 -07:00
Teknium d0c0f2c576 test(kanban): initialize isolated boards for watch resolution 2026-09-07 06:04:06 -07:00
Teknium 8cffac608e fix(kanban): name the resolved board in watch startup
Slim adaptation of anombyte93/hermes-agent@d06d2a49c5; use the canonical board resolver instead of inferring the slug from a path. Live isolated CLI probe confirms current-file, env and explicit board banners; event delivery remains live.

Co-authored-by: Hayden (Atlas agents) <212644172+anombyte93@users.noreply.github.com>
2026-09-07 06:04:06 -07:00
Teknium 6798a9b8a4 fix: bound skill update wait budget and lingering fetch workers 2026-09-07 05:59:43 -07:00
Teknium 36b0b6c9f2 fix: enforce complete fetch deadlines and inherit request context 2026-09-07 05:59:43 -07:00
liuhao1024 47887693c6 fix(skills): skip orphaned hub entries and bound per-fetch time in update checks
check_for_skill_updates() fetched every lock-file entry remotely, even
when the entry's install directory no longer existed, and each fetch had
no wall-clock bound — a few dead sources turned a routine
`hermes skills update` into a multi-minute stall (#104291).

- Entries whose recorded install_path resolves but does not exist are
  reported as "orphaned" and skipped without a remote fetch;
  unresolvable paths keep the previous fetch behavior.
- Each fetch now runs under a daemon helper thread with a hard timeout
  (default 30 s) and degrades to "unavailable" when abandoned.
- `hermes skills check` prints a removal hint for orphaned entries.

Fixes #104291
2026-09-07 05:59:43 -07:00
Teknium c0c6b31543 fix(update): stream build progress without concealing silent stalls
Retain partial-line output, UTF-8 decoding, failure output and cancellation cleanup. Based on streaming investigations by Artemonim (#101850) and lEWFkRAD (#104843); gateway tee adapted from fangliquanflq (#97402). Live Linux child/tee probe: withheld or dropped on base, visible in 0.02 seconds after. Campaign-locked tests and native Windows proof are pending.
2026-09-07 05:56:50 -07:00
Teknium d0c90039a7 fix(messaging): keep profile status truthful without credential inheritance
Preserve the two contributor fixes, slim them to two behavioral invariants, and enter explicitly requested homes even inside a nested scope. Real native remote Desktop changes Disabled to gateway_stopped for default and named profiles; direct API controls preserve explicit disable and empty-profile isolation. Unit A/B and regression suites remain queued under the shared campaign lock.
2026-09-07 05:56:33 -07:00
vectorcontext e24f07239f fix(dashboard): reclassify profile=default as current when it resolves to the process home
The desktop app always sends profile=default on GET /api/messaging/platforms.
_is_current_profile() recognized only None/""/"current" as the dashboard's
own profile — NOT the string "default" — so a single-profile install (the
standard `hermes gateway setup` flow: token in .env, no platforms: section in
config.yaml) entered the profile-scoped branch of _config_profile_scope().
That branch derives platform enablement from config.yaml only and never calls
load_gateway_config()'s env-override pass (which enables the platform when the
token is in the environment). Result: a platform connected via .env reported
enabled=false, state="disabled" while it was actually running. The unscoped
GET (no profile param) correctly reported enabled=true, state="connected".

Fix: classify by resolved path, not by string. After _is_current_profile()
fails, _config_profile_scope() now resolves the requested profile dir and
compares it against get_process_hermes_home().resolve() — the same comparison
_is_other_profile() already uses. When they match (profile=default on a
default-home process), yield None (no override), taking the unscoped path that
calls load_gateway_config(). A named-profile process (`-p worker`) has a
different HERMES_HOME, so its profile=default resolves to a different directory
and still scopes correctly — cross-profile secret isolation is preserved.

The scoped branch's config.yaml-only enablement is DELIBERATE:
load_gateway_config()'s env pass reads os.environ and would leak the root
install's tokens into a genuinely different profile's state. This fix only
reclassifies requests that name the process's OWN home; it does not touch the
scoped branch or gateway/config_env.py.

Refs #104614
2026-09-07 05:56:33 -07:00
liuhao1024 9098c9a65a fix(dashboard): guard empty-required_env platforms in the scoped enablement fallback
all() over an empty tuple evaluates True, so the scoped credential
fallback reported platforms with required_env == () (whatsapp, yuanbao,
api_server, webhook, a2a, msgraph_webhook, relay, whatsapp_cloud) as
enabled=True with no config entry and no credentials. Add the
bool(required) guard to the enabled computation (per review suggestion)
and to the configured field, which came from the same all()-over-empty
expression and reported configured=True for the same shape — the
unscoped branch reports enabled=False / configured=False there, so the
scoped branch now agrees.

Adds tests/hermes_cli/test_web_server_scoped_enablement.py covering the
empty-required_env shapes, the explicit-enabled precedence, and the
credentials-present path.

Co-authored-by: crazyief <8566250+crazyief@users.noreply.github.com>
2026-09-07 05:56:33 -07:00
liuhao1024 5dfa2f8374 fix(dashboard): env credentials enable a platform on the profile-scoped messaging status path
The scoped branch of _platform_enablement consulted only config.yaml's
platforms: section, but the `hermes gateway setup` wizard writes .env
credentials and never a platforms: entry. The desktop always sends
?profile=default (normalizeProfileKey maps the primary profile to
`default`), so the Settings - Messaging page showed a working bot as
"Disabled" while /api/status reported it connected (#104614).

Mirror _enable_from_env (gateway/config_env.py): env credentials alone
enable a platform, an explicit enabled: false still wins. Only the
profile's own .env (env_on_disk) is consulted, so the root install's
os.environ credentials still never leak into a profile's state.

Fixes #104614
2026-09-07 05:56:33 -07:00
Teknium f4fa6bf2dc fix(desktop): validate packaged archive and renderer before Windows success 2026-09-07 05:55:26 -07:00
Teknium 44a583fcc8 fix: retain profile idle activity after session removal 2026-09-07 04:56:38 -07:00
Teknium 2f090fbdec fix(serve): run idle skill maintenance on the existing timer
Desktop-only backends now poll curator and personal/org skill sync without another long-lived loop. Respect active turns, the actual idle threshold, and messaging gateway ownership. Credit Jackal991 for the report and candidate #95453.
2026-09-07 04:56:38 -07:00
HoneyTyagii 381d6064d7 fix(desktop): preserve custom Linux launcher entries when opted out
Salvage #101453 (03a3f466d38134ba416764185884b3d655197a1d). Preserve its opt-out and first-run behavior; replace predicate-mocked tests with one native config/filesystem invariant and clarify XDG docs. Real venv/XDG probe: base clobbers custom entry, fix preserves it; targeted suite 94 passed.
2026-09-07 04:55:17 -07:00
Teknium 285829c3fe test(kanban): exercise acceptance races through real run lifecycle 2026-09-07 04:46:54 -07:00
Teknium dccdf31732 test(kanban): use distinct IDs for paginated acceptance fixtures 2026-09-07 04:46:54 -07:00
Teknium ac07da2674 fix(kanban): enforce declared PR acceptance at completion boundary 2026-09-07 04:46:54 -07:00
Teknium e6df8675f5 fix(config): diagnose unavailable home links without claiming YAML corruption
Keep externally managed directory links and permissions intact during home
initialization. Refuse missing targets rather than creating directories on an
unmounted volume's underlying filesystem. Report link, target, mount and access
guidance through doctor while preserving config.yaml.

Extract the home initialization phase into config_home, and memoize successful
resolved aliases so plugin discovery cannot repeat chmod after losing the
symlink spelling. Live Linux doctor PTY A/B verified directory and root links,
plain paths, missing targets, mount-style missing paths and file conflicts.
Targeted invariant tests are queued under the campaign's shared serial lock;
this checkpoint is not a unit-suite or merge-readiness claim.

Inspired by #104774 and #103735; deliberately does not auto-create external
targets or silently ignore an unavailable sessions directory.

Co-authored-by: ca-shrimp <320556551+ca-shrimp@users.noreply.github.com>
Co-authored-by: Craig Richardson <craigrichardson@Craigs-Mac-mini.local>
2026-09-07 04:46:37 -07:00
Teknium c4a5deeffa test: retry busy shared-metrics writes after contention 2026-09-07 01:37:43 -07:00
Teknium aacaeff42f fix: share goal controls while messaging turns are active 2026-09-07 00:51:41 -07:00
Teknium ebf2473325 refactor: share CLI goal commands across interactive surfaces
Keep parsing, contracts, gates and persisted goal mutations in one dispatcher. Adapters retain authorization, rendering and scheduling; TUI drafting resolves the target session profile off the RPC reader. Document ACP as unsupported rather than implying a goal loop exists.
2026-09-07 00:51:41 -07:00
Bruce Xu 5f561efe67 fix(plugins): recover when hook worker cannot start 2026-09-07 13:11:36 +05:30
Teknium 08b140d14e fix: gpt-6 Astra on Codex OAuth gets the 85% compaction autoraise
Codex OAuth caps gpt-6-astra at the same 272K window as gpt-5.4/5.5/5.6,
so the global 50% trigger compacted at ~136K. Extend the existing
codex_gpt55_autoraise gate to any slug containing "astra" (minus the
opt-in -900k picker variants, which already unlock the wider window).
Other routes (OpenAI direct, OpenRouter) keep the user threshold.
2026-09-06 23:08:45 -07:00
Teknium 634109b7aa Merge pull request #103534 from NousResearch/fix/goal-judge-wait-on-subagents
fix(goal): the judge can WAIT on delegated subagents (4 of 5 nudges re-poked a waiting orchestrator; live 3/3 CONTINUE → 3/3 WAIT)
2026-09-06 12:03:15 -07:00
Teknium afb4e080f6 Merge pull request #103496 from NousResearch/fix/goal-judge-own-processes-only
fix(goal): the judge sees only its own session's background processes; pid/session waits expire after 30 min (parked 3h22m on a grandchild's poller)
2026-09-06 12:03:05 -07:00
Gille 485aaf69b7 fix(local-runtime): find nvidia-smi in WSL driver path 2026-09-06 23:34:20 +05:30
kshitijk4poor b114641c88 refactor(state): fold the speculative-open retry into acquire's loop; keep real inode-swap tests
- acquire(): the discard-and-recurse path becomes one more iteration of the existing wait loop;
  the two inline 'with lifecycle_lock: _teardown(db)' copies reuse _teardown_generation, and the
  type-narrowing asserts go away with the recursion. release() reads generation.path directly.
- Restore the real os.replace inode swaps in test_state_db_file_identity.py and the registry tests:
  those files carry no windows_only marker so they never run on Windows, and the monkeypatched
  predicate stopped exercising the stat->identity->halt path anywhere.
- Drop the auto-archive change and its 4 tests: on main the sweep gets a bare SessionDB and
  db.close() already releases a registry-shared handle, so the described NameError leak only
  existed on this branch's earlier head. trace_upload: acquire(None) already defaults.
- Trim the barrier tests to the invariant pair (retired drain must not lift a pending current
  teardown; replacement not published before the last close settles) plus the raising-close
  settlement; comments say the WHY once.
2026-09-06 22:56:51 +05:30
joaomarcos aad74f26f9 fix(state): coordinate SessionDB teardown with active writers
The #102827 corruption is pure zero holes -- frames lost across a WAL
generation. SessionDB.close() produces exactly that when it runs against a
file another live handle is still writing: PRAGMA wal_checkpoint(PASSIVE),
then the connection close that lets SQLite unlink -wal/-shm. The dangerous
event is a physical close overlapping any other live physical lifetime for
the same path, so both sides of it are closed here.

Late write vs. close: a cron watchdog timeout only stops waiting, and
ThreadPoolExecutor.shutdown(wait=False) cannot interrupt a worker already
inside run_conversation. The agent and its registry reference are now held
until that worker's Future completes, so its last frames land before any
checkpoint.

Close vs. open: the per-path barrier now COUNTS admitted teardowns. A path
can own several closes at once -- the current generation's final release and
a retired generation's drain are admitted independently under the registry
lock, and the per-path mutex only serializes teardowns that already entered
it. With one bare event per path, a releasing thread descheduled between
generation removal and the mutex let the next teardown to settle remove and
signal the shared event: close_all() returned over a pending close and
acquire() published a replacement writer on top of a handle still inside
checkpoint/unlink. _TeardownBarrier tracks event + pending count,
_admit_teardown_locked registers each close in the same lock section that
removes the generation, and only the last settled teardown lifts the
barrier. Physical I/O stays outside the registry lock and unrelated paths
still progress independently.

The auto-archive sweep called release_or_close in its finally while the
import was local to a different function, so every eligible sweep raised
NameError, the outer except Exception swallowed it at debug level, and the
borrowed registry reference was never returned -- a holder leak that pins a
retired generation open. The helper is now bound in the calling scope.

Remaining in-process writable SessionDB() call sites (trace upload, the
API-server profile cache, the web-server writable paths, startup schema
reconcile) go through the canonical registry acquire/release_or_close, and
gateway maintenance borrows pinned handles instead of iterating an unpinned
snapshot.

Regressions: overlapping final releases of the current and retired
generations in both orderings with the first paused before the lifecycle
mutex, teardown-error settlement, an unrelated-path control, and refcount
assertions for the auto-archive sweep on success, on failure, across
repeated sweeps and with auto-archive disabled.

Fixes #102827

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzxCWw6SuHXhXMdkiEwMa2
2026-09-06 22:56:51 +05:30
Teknium f3400ce745 fix(cli): blank user turn no longer crashes foreign session discovery
_first_user_line indexed splitlines()[0] on a whitespace-only user message
(image-only / tool-only turn) and raised IndexError, which aborted the whole
CLI picker and the desktop session.foreign.list RPC. partition("\n") yields
"" for blank text and the loop moves on to the first real user line.
Reported first in #92290.
2026-09-06 09:09:41 -07:00
Teknium fe49acb670 refactor(desktop): import-session entry is a sidebar nav row; browser module named as a foreign_sessions sibling
- Sidebar: the entry joins SIDEBAR_NAV (same chrome, active state, data-tour
  handle as the other rows) instead of a one-off Button below the rail;
  i18n moves to sidebar.nav['session-import'].
- Reuse common.retry / common.refresh / common.back instead of duplicating them
  under sessionImport in six locales.
- hermes_cli/foreign_session_browser.py -> foreign_sessions_browser.py so it
  sorts as a sibling of the foreign_sessions module it extends.
2026-09-06 09:09:41 -07:00
Adolanium dfd0660fd7 fix(desktop): skip inaccessible logs during foreign session discovery
One unreadable or rotated log must not hide the rest of the list; discovery
tolerates OSError from resolve()/stat() per entry and reuses the stat result.
2026-09-06 09:09:41 -07:00
kshitijk4poor a262b2e372 refactor(update): drop the tombstone comment at the old matcher site and a duplicated exactness leg 2026-09-06 20:47:52 +05:30
kshitijk4poor 80d636b7c5 test(update): drop the launchd serve-isolation test already covered by the serve branch
Serve/dashboard runtimes return from match_runtime_outcomes before
_gateway_named_in is reached, and the systemd flavour of this contract is
already pinned by test_serve_reconciles_against_its_own_unit_vocabulary.
2026-09-06 20:47:52 +05:30
mengtanx b4b6235239 fix(update): credit launchd ai.hermes.gateway in fleet reconciliation (#103679)
The restart phase records macOS LaunchAgent labels (ai.hermes.gateway).
match_runtime_outcomes used a substring check for "hermes-gateway", so a
successful Desktop update on the default profile always tripped
"Planned runtimes the restart phase never touched" and exited 1.

Use the exact systemd/launchd/s6 matcher for both plan reconciliation
and abort-recovery so the two cannot drift.
2026-09-06 20:47:52 +05:30
Teknium 03e9f4caee Merge pull request #104284 from NousResearch/fix/nous-anthropic-chat-wire
fix(nous): route anthropic/* over chat/completions by default; the native Portal route re-writes the cache on 14-20% of calls (nous.anthropic_wire)
2026-09-06 07:17:48 -07:00
Teknium 54e24ae1fa fix(nous): route anthropic/* over chat/completions by default (nous.anthropic_wire)
Portal serves anthropic/* on two routes. The native /v1/messages wire, which
Hermes has used since 02d5e23085, re-writes the previous turn's prompt cache
on 14-20% of consecutive calls in concurrent tool loops; the chat/completions
route does not. Measured 2026-09-06, 20 concurrent sessions x 6 tool calls on
Fable 5.1, same account, same hour, same first-party pin:

  Nous /v1/messages          180 pairs, 25 stuck (13.9%); 3 earlier 40-session
                             runs 15.0 / 17.3 / 20.2%; unchanged by the pin
  Nous /v1/chat/completions  320 pairs (2 runs), 0 stuck
  OpenRouter direct, pinned  161 pairs, 0 stuck

"stuck" = cache_read on call k+1 equals cache_read on call k instead of
call k's prompt size: the last write was not visible, the turn (~28K) was
written twice. Every stuck pair had byte-identical system, tools and
per-message shas, so it is not client-side mutation. At $10-20/M for
cache writes that is 15-20% of a fan-out's write bill.

The cause is inside the portal's native route (every element of its
outgoing request reproduces clean from outside; NousResearch/api#227
carries the diagnostics). Until it is fixed, anthropic/* rides
chat/completions. nous.anthropic_wire: native opts back in. Cost of
chat: prior-turn thinking travels as OpenAI-style reasoning fields, and
cache_control scopes are translated by the portal's adapter.

Tests: new test_nous_anthropic_wire_default.py reads the knob through the
real config loader in a temp HERMES_HOME and through
resolve_runtime_provider (both settings); the existing native-wire
contract suite selects native via an autouse fixture so the wire keeps
working for the flip-back. Live: a real AIAgent on this branch,
provider=nous + anthropic/claude-fable-5.1, dispatches to
chat/completions with the session_id intact.
2026-09-06 05:45:44 -07:00
Teknium 91b4d9fb67 test(fallback): keep the two persisted-config invariants; PTY harness for the picker error path
Trim the salvaged suite to the behaviour contracts: a picker exception or Ctrl+C leaves
config.yaml's model exactly as snapshotted (parametrized), and an absent active_provider
stays absent through snapshot+restore. The mock-dispatch tests (asserting calls into our own
helpers) are dropped. evals/cli_fallback_add_picker_error.py drives the real
`hermes fallback add` under a Linux PTY into an ordinary picker OSError and checks the
persisted model: stranded on base, restored after.
2026-09-06 05:41:46 -07:00
Xipong 33cefdd8e5 fix(fallback): restore primary route after picker errors 2026-09-06 05:41:46 -07:00
Teknium 0edab1ac4d test(profiles): two invariants for the truncated-JSON refusal, comment trimmed to the why
Parametrized refusal test (bare/fenced/uppercase-fenced truncated objects) asserts
profile.yaml is byte-identical afterwards; one prose test keeps the lenient
fallback contract. Replaces five near-duplicate tests from #104075.

Campaign tracker: https://github.com/NousResearch/hermes-agent/issues/104154
2026-09-06 05:34:01 -07:00
Edizzier 1f16bbf108 fix(cli): refuse to persist a truncated JSON reply as profile description
hermes_cli/profile_describer.py's aux-LLM response parser is documented as
"lenient, never raises": when the reply doesn't parse as JSON via
_extract_json_blob, it falls back to treating the WHOLE raw reply as plain
prose and persists it (truncated to 280 chars) as profile.yaml's
description.

That fallback exists for models that ignore the JSON-output instruction and
just answer in prose -- reasonable. But it doesn't distinguish that case
from a reply that DID start out as the requested JSON object and was cut
off mid-object by the aux model or transport. _extract_json_blob requires a
matching closing brace, so a truncated object (no `}` at all, or one cut
off mid-string) returns None just like plain prose does, and the fallback
then persists the raw JSON fragment verbatim: literal leading `{`, `\n`
escapes, a sentence chopped mid-word (#104067).

Fix: when parsing fails, check whether the reply (after the existing
code-fence strip) still looks JSON-shaped (starts with `{`). If so, it's a
malformed/truncated structured reply, not prose -- refuse it and return
ok=False instead of persisting the fragment. Only fall through to the
raw-text-as-prose fallback when the reply never looked like JSON to begin
with, so genuinely prose-only aux replies keep working exactly as before.
Also logs one INFO line on the refusal path, per the issue's note that the
silent fallback made this undiagnosable.

Update (review feedback from @jerrygooch): profile_describer._FENCE_RE was
case-sensitive, so an uppercase ```JSON fence wasn't stripped -- stripping
left "JSON\n{..." which doesn't start with "{", so a truncated response
under that fence variant still fell through to the prose fallback and got
persisted, defeating the fix for that case. Added re.IGNORECASE to
_FENCE_RE, aligning it with kanban_specify._FENCE_RE (which already used
re.IGNORECASE), and added a regression test for the uppercase-fence case.

Added tests/hermes_cli/test_profile_describer.py coverage: a truncated
object cut off mid-sentence (the real-world shape from the issue), one cut
off right after the opening brace, the same truncation under a ```json
fence, the same truncation under an uppercase ```JSON fence, and a
regression guard that a genuine plain-prose reply still hits the existing
lenient fallback unchanged.

Fixes #104067

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUtGTop5iGKi5vfWnKnwET
2026-09-06 05:34:01 -07:00
kshitijk4poor bc1330eebc refactor(update): name the success invariant in _receipt_looks_unfinished; one predicate contract
The tail clause was correct only by ordering (exit_code != 0 there meant
exit_code is None). Name what it encodes: a stop_reason counts only when
nothing vouched for success. Same truth table. The three literal-dict tests on
the predicate collapse into one parametrized contract; the handoff-exit test
binds to COMMAND_BOUNDARY_STOP_REASON instead of re-spelling it.
2026-09-06 14:53:33 +05:30
kshitijk4poor dad698d88c test(update): pin the refused-receipt shape the stop_reason clause exists for
update_contract writes {"outcome": "refused", "stop_reason": <code>} with no
exit_code; that is the one production receipt where the stop_reason clause in
_receipt_looks_unfinished is load-bearing. The previous negative control used
exit_code=1, which the exit_code branch already catches. Docstring reworded:
a KeyboardInterrupt never lands on a success receipt (the boundary finalize is
a no-op once the inner path finalized).
2026-09-06 14:53:33 +05:30
tachyon-r f7e56d4256 test(update): isolate fleet restart tests from launchd 2026-09-06 14:53:33 +05:30
tachyon-r 6e0fba4e58 refactor(update): share command-boundary receipt reason 2026-09-06 14:53:33 +05:30
tachyon-r cd27df3c2e fix(update): ignore successful receipt stop reason 2026-09-06 14:53:33 +05:30
kshitijk4poor 16759c0ec6 test(serve): WS sessions counted at the ASGI boundary for any route; exit only after grace with no client and no running turn; only SSH-isolated backends arm it
Red on origin/main (module absent).
2026-09-05 20:44:51 +05:30
kshitijk4poor 60ca93cebd fix(doctor): probe the Docker daemon with 'docker version', not 'docker info' (#72927)
docker info needs the /info endpoint, which socket proxies such as tecnativa block by
default, so doctor reported 'docker daemon not running' against a fully working
DOCKER_HOST. docker version hits /version and is what the backend itself probes with.
2026-09-05 19:01:22 +05:30
Teknium 8477292f0f fix(goal): a delegation WAIT lifts as soon as a batch returns; the TUI passes the delegation count too
Independent review found the delegation WAIT was a plain timer: the batch
results arrived with 1,199 s left on a 1,200 s wait and nothing re-judged,
so integration sat unfinished until the timer ran out. The wait now records
how many batches it was set for (GoalState.waiting_on_delegations) and
is_waiting() lifts it the moment fewer are live, so the next idle tick
resumes with the result in hand. A plain timed wait (no delegations) is
unchanged. The TUI/Desktop goal path now passes the live delegation count
to the judge like the CLI and gateway do.

Live with the real auxiliary judge: WAIT 900 s with 4 live batches; one
returns -> is_waiting() False immediately.

Test: parked with 4 live, lifted at 3, plain timed wait unaffected.
2026-09-05 06:27:45 -07:00
Teknium 50a13d53eb fix(goal): the judge can WAIT on delegated subagents (4 of 5 nudges in one run re-poked an agent that was waiting on workers)
JUDGE_SYSTEM_PROMPT allowed WAIT only for "a background process listed
below" or a stated backoff. A fan-out orchestrator's in-flight work is
delegated subagents, which are not registry processes, so when the agent
said "waiting on 4 worker batches, nothing to dispatch" the judge answered
CONTINUE ("no background process is listed to gate on"). In the
1,393-agent run 4 of 5 nudges fired 6-153 s after exactly such a turn and
each bought a status recap: 19 API calls at ~450K context, ~$4.9, and no
work a batch-complete notification would not have produced.

The judge now receives an "Active delegations: N batch(es) still running"
line (count of live async_delegation records whose parent_session_id is
this session, via the existing _session_records selector) and a WAIT
branch for it: wait_for_seconds 600-1800 when the response says it is
waiting on them with nothing dispatchable. Both goal-loop callers (CLI,
gateway) pass the count.

Live A/B with the real auxiliary judge on the run's response shape
("4 worker batches still running ... nothing else is dispatchable until
these return"): main 3/3 CONTINUE, branch 3/3 WAIT 1200 s.

Tests (2): the prompt carries the delegations line only when N > 0 and
a wait verdict parses through; count_active_delegations is scoped to the
spawning session and ignores completed records.
2026-09-05 06:24:06 -07:00