* feat(i18n): layered catalogs — plugin packs and user overlay over bundled locales
* feat(tui): i18n layer — en catalog, nanostore runtime, RPC pack loader, _keys.tui.json emitter
ui-tui/src/i18n/: en.ts (facade over topical siblings under en/), types.ts
(Translations + dotted TranslationKey derived from en), runtime.ts ($locale/
$catalog atoms, translateFrom active→en→key, pack merge with string→fn
wrapping for {0}/{1} placeholders), loader.ts (display.language →
i18n.catalog {lang, surface:'tui'}, English when the method is missing),
useT()/useLocale() hooks, t() for non-React code. useConfigSync feeds the
loader from the existing config.get full hydration. `npm run i18n:keys`
writes locales/_keys.tui.json (sorted flat key list) and runs before build.
* feat(plugins): provides_locales manifest field, ctx.register_locale/register_locale_dir, manifest-only language packs
* chore(tui): split en catalog siblings by lane (slash sibling)
* feat(plugins): validate language packs — parse, text-only, key-subset WARN against en / _keys exports
* feat(tui_gateway): i18n.languages / i18n.catalog RPC + regenerated contracts
* feat(config): display.language accepts any supported_languages() id, refuses unknown ids with the list
* docs(i18n): language packs user guide, pluggable display.language, plugin developer section, AGENTS notes
* feat(plugins): report language-pack layers in the mid-run activation summary
* feat(tui): wire status bar, composer placeholders, hotkey help and approval/clarify/confirm prompts through i18n
StatusRule maps compared state values (ready/running…/summoning) to catalog
text at render via displayStatus(); hotkeys()/placeholder() resolve lazily so
a pack that arrives after boot applies. Catalog grows to 81 keys.
* feat(desktop): pluggable app locales — registry, host.i18n.registerAppLocale, backend packs, keys emitter
- Locale widens to string (BundledLocale keeps the union); TRANSLATIONS stays
the bundled record and every consumer resolves through the registry.
- src/i18n/registry.ts: registerAppLocale(id, {endonym, rtl, translations})
layers partial packs (nested or flat dotted) over bundled/en via
mergeTranslations; a string over a function-valued en entry becomes a
positional {0}/{1} formatter; $appLocaleVersion bumps so translators
re-render; per-source disposers + replaceAppLocaleSource for atomic swaps.
- Backend packs: i18n.languages + i18n.catalog {surface:'desktop'} feed the
registry as source 'backend' (method-not-found is silent); re-synced on
socket open, display.language change and profile switch. A saved pack-only
language is promoted once its pack registers.
- SDK: host.i18n.registerAppLocale / languageOptions; ctx.i18n.registerAppLocale
tracked for unload. Docs in the desktop plugin SDK guide + skill reference.
- Language switcher lists bundled ∪ registered ∪ backend, endonym-only; RTL
from the registry (applyDocumentLocale takes rtl).
- npm run i18n:keys emits locales/_keys.desktop.json (wired into build).
* i18n(cli): route /topup + /subscription copy through t() (cli.billing.*, cli.subscription.*)
Module-level copy tables and modal choice tuples in cli_billing_mixin.py froze
English at import, before display.language was known. They are now key tables /
builder functions evaluated at call time; every user-facing line in the /usage
balance block, /subscription and the five /topup screens reads the catalog.
Choice VALUES stay English identifiers. Fragment-assembled status lines
(Plan: … → cancels · $x left · renews …) become full templates.
* i18n(gateway): exec-approval card contract + base/run/run_busy/run_inbound replies through t()
- base_exec_approval: EA_* English constants stay; add ea_header_text()/ea_reason_label_text()/
ea_smart_deny_line_text()/ea_default_reason_text()/ea_action_labels()/approval_timed_out_notice()
accessors; deadline + timed-out notice resolve via gateway.exec_approval.*
- BasePlatformAdapter._EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/_EA_ACTION_LABELS become
properties (adapters still shadow them with markup class attrs)
- run.py: provider error replies table holds catalog keys; _CONTEXT_OVERFLOW_REPLY -> _context_overflow_reply()
- run_busy/run_inbound: typed approval + slash-confirm matchers accept English ∪ approval.inputs.* (t())
- locales/en.yaml: gateway.exec_approval/busy/errors/... namespaces
* i18n(cli): wire modal, loops, agent-setup mixins through t() (cli.* keys)
* i18n(platforms): route Slack, Matrix and Feishu user-facing text through t()
Exec-approval markup overrides (_EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/
_EA_ACTION_LABELS) become per-call properties over the shared
gateway.exec_approval.* contract keys, so Slack's 3000-char section budget
measures the resolved template. Slack _APPROVAL_DECISIONS/_CONFIRM_DECISIONS,
Feishu _APPROVAL_LABEL_MAP and Matrix _EA_LEGEND/_EA_TYPED_HINT turn into
key tables resolved at click time; the Matrix typed hints become whole
sentences per offered tier instead of spliced fragments. Slack button labels
are cut to 75 chars and select placeholders to 150 after translation; the
model-facing clarify fallback answer ('choice N') stays English while the
card copy localizes.
locales/en.yaml gains the gateway.exec_approval.* contract keys plus the
platform.shared.* / platform.slack.* / platform.matrix.* / platform.feishu.*
namespaces (and the keys for the other adapters wired in follow-up commits).
* i18n(gateway): run_turn / run_turn_runner / approval-settle copy through t()
- status hints, proxy errors, background task notices, progress heartbeats, session info lines
- tool progress chrome (tool_head/tool_pending/tool_preview/tool_verbose) shared by base.format_tool_event
- run_turn_runner:1406 Chinese clarify placeholder -> gateway.clarify.native_stream_placeholder (zh text kept in zh.yaml)
- _UNEXPECTED_SILENCE_REPLY/_CLARIFY_EXPIRED_NOTICE -> accessor functions
* i18n(platforms): route Google Chat and Teams user-facing text through t()
Google Chat clarify card, typing placeholder, orphan-card labels and the whole
/setup-files reply set (module constants become platform.google_chat.setup_files.*
keys resolved at reply time). The attachment-fallback notice that shipped
hardcoded in Spanish is keyed with an English en value; es.yaml carries the
original Spanish text for those four keys.
Teams approval card header/reason use the gateway.exec_approval.* contract,
_APPROVAL_LABELS becomes a key table resolved at click time, and the meeting
summary writer resolves its section headings/fallbacks per render.
* i18n(platforms): route LINE, WeCom, email, DingTalk, IRC and Home Assistant text through t()
LINE default copy constants become catalog keys resolved in __init__ (the
LINE_*_TEXT / extra.* operator overrides still win); the busy-ack bypass
matcher keys on the leading emoji marker only, so it keeps firing once the
gateway busy heads are localized. WeCom media size/format notices that shipped
hardcoded in Chinese are keyed with English en values and zh.yaml carries the
original Chinese text. DingTalk emotion bubbles resolve per send.
* i18n(cli): route /model switch output and -q status lines through t() (cli.model.*, cli.single_query.*)
Switch-summary labels shared with the gateway reuse gateway.model.* keys
(provider/context/max-output/capabilities/prompt-caching); CLI-only variants
(glyph or no-backtick forms) live under cli.model.*. The hand-padded /model usage
block becomes a (form, description-key) table padded at render time so the
command syntax stays fixed while descriptions translate. -q 'Error:' reuses
gateway.model.error_prefix.
* i18n(cli): route TUI panel/hint/placeholder copy through t() (cli.tui.*)
_APPROVAL_CHOICE_LABELS and _TUI_MODAL_HINTS become key tables resolved at
render time; vault/sudo panel bodies are one catalog value per panel split on
newline; inline plurals use <key>_one/<key>_other. Adds the cli.* namespace
(shared/tui/voice/render/subagents/dock) to locales/en.yaml.
* i18n(cli): voice/wake-word CLI copy through t() (cli.voice.*)
RuntimeError texts raised in _voice_start_recording are human copy (callers
print {e}) and are keyed; the Termux requirement-check match stays English.
Wake state ids stay internal, only their labels localize.
* i18n(cli): live-work dock, subagent monitor and render copy through t()
cli.subagents.* / cli.dock.* / cli.render.*; count fragments pluralize via
_one/_other keys, verdict table holds keys resolved at paint time so width
clipping measures the translated text.
* i18n(gateway): unauthorized/pairing, voice, topics, shutdown, startup, notifications, kanban pings through t()
* i18n(cli): move tips + composer placeholders into the catalog (tips.tNNN / tips.placeholder.pNN)
get_random_tip()/get_random_composer_placeholder() pick a key from the English catalog
(the parity baseline, probed once per process) and resolve it through t() for the active
language, so language packs translate tips like any other string. Also lands the cli.*
en.yaml namespace consumed by the CLI info/help/error-copy wiring in the next commit.
* i18n(cli): wire chat-turn + session mixins through t(); kanban log trimmer matches t() output
* test(cli): assert TUI/dock/voice copy via t(key); prove labels resolve at render time
Pinned-English assertions in the approval-UI, live-work dock and voice tests now
go through the catalog. New test swaps the catalog after import and checks the
approval panel + hint row follow it (the reason _APPROVAL_CHOICE_LABELS and
_TUI_MODAL_HINTS became key tables).
* i18n(cli): route CLI info/help/error copy through t() (cli.* namespace)
cli_info_mixin: /help consumes CommandDef.describe() (added to commands.py: slash.<name>.description
with fallback to .description), section titles/skill/quick-command headers, /tools, /toolsets,
/usage labels, /context, /whoami, /insights, /gateway status, tool-progress labels, bang-shell
denials, MCP config-watch + /reload-mcp confirm/reload lines, /reload-skills, and the session-store
warning all read the catalog at call time (module-level label tables became functions so the
active language is honoured after startup). cli.py: worktree cleanup, tirith warning, show_config
(labels re-padded at print time), quick/plugin/skill slash-command errors, ambiguous-command hint,
stdin error, gateway start, profile warning. cli_chat_error_copy / cli_unknown_command /
cli_output / cli_init_mixin: chat error panel copy, did-you-mean lines, n-more / yes-no prompt
(localized affirmative initial alongside 'y'), unknown-toolsets warning.
* i18n(w1a): wire agent display/explainers/approval + slash registry/help through t()
- hermes_cli/commands.py: CommandDef.describe() resolves slash.<name>.description
at call time; category labels via slash.category.*; help/alias/usage suffixes
via slash.shared.*; gateway_help_lines and commands_platforms/slash_exec use them.
- agent/display.py: display.verb.* resolved at call time via get_tool_verb();
bridge/spinner/thinking-verb/cute-row/failure/preview/diff text via display.*.
- agent/turn_explainers.py: exit-reason / persistence-cause tables become call-time
lookups (explainer.exit.*, explainer.persistence.*, explainer.file_mutation.*).
- agent/background_review.py, session_activity.py, context_breakdown.py,
status_output.py: review summaries, iteration progress, context notices.
- tools/approval.py, approval_context.py: approval.summary.*, approval.noun.*,
approval.window.* pluralized keys.
- gateway/slash_commands*.py: remaining raw strings (busy, whoami, platform,
bundles, memory, skills, approvals, set_home, diff, update, debug, profile,
heartbeat, refine, review, subgoal, loop, retry, compress codex path, save,
sessions, model guard/errors, agents rows, topup, login). HISTORY_UNREADABLE
keeps its English constant; callers use history_unreadable() ->
gateway.shared.history_unreadable.
- locales/en.yaml: new approval/display/explainer/slash blocks + gateway leftovers.
* i18n(telegram): route adapter chat copy through t()
Approval card (header/reason/smart-deny as HTML-escaped properties), inline
button labels, callback toasts (cut at Telegram's 200-char cap), model/choice
pickers, clarify/update/slash-confirm prompts, gmail-triage labels and the
inbound-media failure notice now come from the catalog. _UNAUTHORIZED is a
lazy _unauthorized() so the import no longer binds a language. The command
menu carries a language+payload fingerprint (forum scopes re-register on
change) and BotCommand descriptions are cut at 256.
Adds gateway.exec_approval.* (WAVE2 contract), platform.telegram.*,
platform.discord.* and the slash.*.description keys the Discord table shares
with the CLI registry to locales/en.yaml.
* i18n(gateway/platforms): whatsapp_cloud, yuanbao, weixin, signal, api_server copy through t()
- whatsapp_cloud: clarify list/buttons, approve/deny + slash-confirm labels via platform.whatsapp.* (t()-then-truncate at 20/24/72 caps); _EA_HEADER becomes a property wrapping ea_header_text()
- yuanbao: SLOW_RESPONSE_MESSAGE -> slow_response_message() (platform.yuanbao.slow_response_notice; zh keeps the original text); cron-wrapper markers centralized as module constants for strip_cron_wrapper
- api_server: PROVIDER_AUTH_FAILED_LABEL/PROVIDER_RATE_LIMITED_LABEL stay English for run.py matchers; user_text() renders via t()
- signal/_format_wait, weixin voice caption, openai_routes transformed notice
- run_turn: second _UNEXPECTED_SILENCE_REPLY consumer -> accessor
* i18n(discord): route adapter chat copy through t()
Native slash-command table becomes _NATIVE_SLASH_COMMAND_SPECS holding catalog
keys; _native_slash_commands() resolves descriptions, parameter descriptions
and Choice names for the active language, each cut at Discord's 100-char cap,
and the app-command sync fingerprint now includes get_language() so a
display.language change re-syncs. Exec-approval card (gateway.exec_approval.*
contract), slash-confirm / clarify / update views, model+choice pickers,
thread creation, forum titles, voice acks, the response-truncation notice,
the unauthorized-slash security alert and the media upload-size notices all
read from platform.discord.*. Decorator-declared button labels are relabelled
in __init__ (80-char cap); embed titles cut at 256, select placeholders at
150, option label/description at 100. _UNAUTHORIZED is a lazy _unauthorized().
* i18n(cli): wire status-bar, stream, terminal mixins + terminal_input through t(); rename kwargs that shadow t(key)
* i18n: wire hermes_cli/cli_commands_mixin.py slash-command copy through t()
- 431 new leaves under cli.commands.<cmd>.* in locales/en.yaml; 12 rows reuse
existing gateway.* keys (rollback, diff, resume, branch, btw, model, reasoning)
via a _gt() helper so CLI and gateway replies stay identical.
- Module-level English tables (_BUSY_MODE_*, _REASONING_TOGGLES, _HATCH_PROGRESS,
_DIFF_LABELS, _LOCAL_ENGINE_LINES) become call-time catalog lookups keyed by id.
- Verb tables (Enabling/Disabling, Paused/Resumed/Triggered, planned/done,
Updating/Generating) are one full template per variant; plurals use
<key>_one/<key>_other via _tn(); hand-padded column labels (/snapshot list)
translate the value and re-pad at the call site.
- Multi-line usage blocks are single catalog values split with _lines().
- Model-facing system notes and DB-stored reasons stay English (EXCLUDED).
* tests: assert /handoff, /worktree, /login CLI copy via t(key) instead of pinned English
* test(i18n): pin Telegram/Discord adapter catalog wiring
Lazy unauthorized notice, exec-approval contract keys, HTML escaping before
Telegram <b> wrapping, 200-char toast / 256-char BotCommand caps, Discord
100-char app-command text and 80-char button caps, and language-bearing
command-menu fingerprints on both platforms.
* i18n: reconcile cli.shared on/off vs enabled/disabled after lane merge
* i18n: describe() in TUI-gateway slash listings; localize TUI exit resume hint
* i18n(tr): translate bundled catalog + tui pack
* i18n(ja): translate bundled catalog + tui pack
* i18n(ko): translate bundled catalog + tui pack
* i18n(zh): translate bundled catalog + tui pack
* i18n(fr): translate bundled catalog + tui pack
* i18n(af): translate bundled catalog + tui pack
* i18n(uk): translate bundled catalog + tui pack
* i18n(ar): translate bundled catalog + tui pack
* i18n(pt): translate bundled catalog + tui pack
* i18n(it): translate bundled catalog + tui pack
* i18n(es): translate bundled catalog + tui pack
* i18n(zh-hant): translate bundled catalog + tui pack
* i18n(ru): translate bundled catalog + tui pack
* i18n(hu): translate bundled catalog + tui pack
* i18n(hu): translate pre-existing English-valued leftovers (kanban wake, /context, /status, fast labels)
* i18n(de): translate bundled catalog + tui pack
* i18n(ga): translate bundled catalog + tui pack
* test(i18n): fixture matches _normalize_lang(lang, home) signature
* i18n(tui): scaffold userMessages/slashCmd en siblings
* i18n(tui): wire secure prompts + content tables
* feat(tui): i18n — wire billing, subscription, connection-setup and journey overlays
Adds en siblings billing.ts / subscription.ts / connection.ts (namespaces
billing, subscription, connection, journey) and routes every user-facing
literal in billingOverlay, subscriptionOverlay, connectionSetupOverlay and
journey through useT()/messages(). Module-level label tables became lazy
(scopeStillDeniedResult(), verbOf(T, action)); auto-reload rows dispatch on
stable ids instead of label text. Regenerates locales/_keys.tui.json.
* i18n(tui): wire slash ops/wake replies
* i18n(tui): wire pickers (modelPicker, activeSessionSwitcher, petPicker)
* i18n(tui): wire slash core/debug/setup replies
* i18n(tui): wire hubs (agents overlay/panel/controls, skills, plugins)
* i18n(tui): wire slash session/topup/subscription replies
* i18n(tui): wire chat bits (branding, thinking, messageLine, loaders, todo, queued, banner, entry)
* i18n(tui): register t3 siblings (pickers, hubs, secure, content, chatBits) and regenerate keys
* i18n(tui): wire userMessages copy through the userMessages namespace
* i18n(tui): lazy-copy test for userMessages, regenerate _keys.tui.json
* i18n(tui): wire session/gateway/lib text through the TUI catalog (lane t2)
Adds en siblings session.ts, gatewayMsg.ts, libText.ts (namespaces session,
gatewayMsg, libText) and routes user-facing literals in app/{useMainApp,
useSessionLifecycle,useInputHandlers,turnController,createServerRequestHandler,
setupHandoff,createGatewayEventHandler}.ts, gatewayClient displayed reasons,
lib/*, domain/*, hooks/* through t()/messages(). Status-bar state values that
code compares against, backend-matched strings, log lines, model-bound text,
and machine 'error:' prefixes stay literal. Regenerates locales/_keys.tui.json
(232 keys).
* i18n: serve bundled locales/<lang>.tui.yaml under overlay/packs; TUI pack parity test; regen _keys.tui.json (1250)
* i18n: translate pre-existing English stubs in bundled locales (424 leaves, 14 locales)
* tui: i18n-export-en script (English templates for pack translators)
* docs(i18n): bundled TUI packs are the bottom layer of the tui surface
* i18n(ru): translate TUI pack
* i18n(ar): translate TUI pack
* i18n(es): translate TUI pack
* i18n(pt): translate TUI pack
* i18n(ko): translate TUI pack
* i18n(de): translate TUI pack
* i18n(ja): translate TUI pack
* i18n(fr): translate TUI pack
* i18n(tr): translate TUI pack
* i18n(it): translate TUI pack
* i18n(zh): translate TUI pack
* i18n(zh-hant): translate TUI pack
* i18n(hu): translate TUI pack
* i18n(uk): translate TUI pack
1,169 missing keys translated; 81 pre-existing kept byte-identical. Parity OK missing=0 extra=0 placeholder_mismatch=0 empty=0.
Deliberately identical to en: chatBits.branding.mcpSummary ({0} MCP), chatBits.thinking.agentsHint ((/agents)), session.main.voiceStt (◉ STT), session.main.voiceTtsSuffix ( [tts]), slashCmd.core.help.tuiSection (TUI), slashCmd.core.history.hermesTag (Hermes #{0}), slashCmd.debug.heapdump.heapPath (heapdump: {0}), slashCmd.debug.mem.rss (rss), subscription.stepUp.title (Remote Spending — product feature name, as in core catalog), content.faces.* (glyph-only kaomoji).
* i18n(ga): translate TUI pack
* i18n(af): translate TUI pack
* plugin_guard: locale catalogs in language packs step down the agent-config family
A translated status line such as "Updating AGENTS.md" in locales/<lang>.yaml is UI text the loader
reads as a string leaf; it cannot edit a file. The bundled en.yaml itself tripped agent_config_mod
at critical, making any faithful language pack uninstallable. Injection shapes keep full severity.
* plugin_validate_locales: read key exports with utf-8-sig (Windows footgun lint)
* i18n(relay): route relay adapter prompt copy through t(); drop dead import-bound approval header
Adds platform.relay.* (5 keys) to en and all 16 bundled locales, reusing the sibling platform
translations for the confirm buttons and the Other option.
* ci: fix TUI import order, MDX table pipe, main's overflow-warning wording in all locales; fresh-install fixture carries the i18n kernel
- ui-tui/src/i18n/en.ts: perfectionist/sort-imports (slash before slashCmd)
- docs plugins/index.md: escape the | inside the provides_locales table cell (MDX parsed <id> as JSX)
- display.notice.uncompressed_context_overflow: adopt main's wording (names compression.enabled: false
and /compact) in en + 16 locales; the guardrail test pins that phrase
- tests/scripts/test_fresh_source_install.py: the installer tail now resolves CLI text through
agent.i18n, so the fixture tree carries the i18n kernel + en.yaml (not the agent runtime)
* docs(desktop-plugin-sdk): double-backtick the template-literal example (MDX evaluated ${n})
* test(e2e): display.language is validated against the live language set; exclude it from the arbitrary-string set property
* commands: keep the localized COMMANDS/COMMANDS_BY_CATEGORY module __getattr__ after the compat block removal
* build: never write locales/_keys.*.json from the desktop/TUI builds; regenerate the committed desktop key export
The desktop build regenerated locales/_keys.desktop.json in the checkout, so a
hermes update that rebuilt the app left the tree dirty (Desktop update E2E:
'M locales/_keys.desktop.json'). The key exports are committed artifacts pinned
to en.ts by apps/desktop/scripts/i18n-keys.test.mjs and ui-tui i18n:keys:check;
builds read them, never write them. Regenerated after main's new desktop strings.
* test: unbreak two main-red timing tests the PR merge-ref inherits
- test_local_runtime racing fake publishes the modern state record (legacy pid-only
records are rejected since 65ff3ad353; main has been red on this test since)
- test_run_progress_topics ManyProgressLinesAgent waits for the first bubble instead of
a fixed 0.35s, which a loaded CI runner does not always meet
* chore(i18n): regenerate desktop key catalog for main's new strings (model pricing, copy changelog)
* test(e2e): torture-chamber fd monitor confirms a deleted sidecar is still held before calling it a leak
SQLite's WAL last-close unlinks -shm before closing its descriptor (unixShmUnmap, then
unixShmPurge), so a healthy close shows a (deleted) -shm for microseconds; the 20ms poll
occasionally caught that window on the short-lived opener and failed the episode.
* chore(i18n): regenerate desktop key catalog for main's telemetry/consent strings
* chore(i18n): regenerate desktop key catalog after main sync
---------
Co-authored-by: Teknium <teknium@nousresearch.com>
3039 lines
135 KiB
Python
3039 lines
135 KiB
Python
"""Dispatcher: crash/stale/orphan detection, failure accounting and the respawn circuit breaker, memory-aware concurrency caps, the one-shot ``dispatch_once`` pass, worker spawning (``_default_spawn``), worker-log rotation and the long-lived ``run_daemon`` loop.
|
|
|
|
Split out of ``hermes_cli.kanban_db``; origin-resident helpers are reached
|
|
late-bound via ``_kb`` (import-cycle breaking) so monkeypatching
|
|
``kanban_db.<name>`` keeps working.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import os
|
|
import re
|
|
import signal
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass
|
|
from dataclasses import field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from typing import Callable
|
|
from typing import Iterable
|
|
from typing import Mapping
|
|
from typing import Optional
|
|
from typing import TYPE_CHECKING
|
|
|
|
from hermes_cli.quiet_single_query import KANBAN_WORKER_EXIT_TRAILER
|
|
|
|
if TYPE_CHECKING:
|
|
from hermes_cli.kanban_db import Task
|
|
|
|
|
|
# After this many consecutive non-success attempts on a task/profile the
|
|
# dispatcher parks the task in ``blocked`` with a reason — prevents retry storms.
|
|
DEFAULT_FAILURE_LIMIT = 2
|
|
|
|
# Worker log files larger than this at spawn time are rotated.
|
|
DEFAULT_LOG_ROTATE_BYTES = 2 * 1024 * 1024 # 2 MiB
|
|
DEFAULT_LOG_BACKUP_COUNT = 1
|
|
|
|
# Keep a little wall-clock budget for the worker to observe a terminal timeout
|
|
# and make a terminal board call (kanban_block/kanban_complete/kanban_request_review)
|
|
# before max_runtime_seconds kills it.
|
|
KANBAN_TERMINAL_TIMEOUT_GRACE_SECONDS = 30
|
|
|
|
# A healthy worker is still alive for a while after kanban_complete /
|
|
# kanban_request_review returns (final assistant turn, session persistence), so
|
|
# a run's retained worker is only reaped once ended_at is at least this old
|
|
# (two default dispatch ticks).
|
|
TERMINAL_WORKER_REAP_GRACE_SECONDS = 120
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Respawn guard constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Patterns in last_failure_error that indicate a quota / auth blocker.
|
|
# These errors won't resolve by retrying immediately — auto-block instead.
|
|
# The auth family is a curated list, not an open `auth\w*` stem: that stem
|
|
# also matched ordinary English words like "author"/"authored"/"authoring"/
|
|
# "authoritative" in worker progress prose, parking a healthy card forever
|
|
# (#117009).
|
|
_RESPAWN_BLOCKER_RE = re.compile(
|
|
r"\b(quota|rate[\s_\-]?limit|429|403|"
|
|
r"auth|authenticat(?:e|es|ed|ing|ion)|authoriz(?:e|es|ed|ing|ation)|"
|
|
r"authoris(?:e|es|ed|ing|ation)|authz|"
|
|
r"unauthorized|forbidden|billing|subscription|"
|
|
r"access[\s_]denied|permission[\s_]denied|"
|
|
r"invalid[\s_]api[\s_]key)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# Within this window a completed run counts as "recent proof"; don't re-spawn.
|
|
_RESPAWN_GUARD_SUCCESS_WINDOW = 3600 # 1 hour
|
|
|
|
# Cooldown after a rate-limited (quota-wall) requeue before re-spawning. Without
|
|
# it the task would re-spawn on the very next tick and bounce off the same quota
|
|
# wall, burning a worker slot every tick for hours. Overridable via
|
|
# ``HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS``.
|
|
DEFAULT_RATE_LIMIT_COOLDOWN_SECONDS = 300 # 5 minutes
|
|
|
|
# Within this window a GitHub PR URL in a comment blocks re-spawn.
|
|
_RESPAWN_GUARD_PR_WINDOW = 86400 # 24 hours
|
|
|
|
_RESPAWN_GUARD_PR_URL_RE = re.compile(
|
|
r"https?://github\.com/[^/\s]+/[^/\s]+/pull/\d+",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class DispatchResult:
|
|
"""Outcome of a single ``dispatch`` pass.
|
|
|
|
``kanban.default_assignee`` applied this tick before spawning (#27145). Surfaces the auto-assignment to
|
|
telemetry / CLI / dashboard so the operator can see when the dispatcher is acting on the fallback rule
|
|
``kanban.max_in_progress_per_profile`` (#21582). Each entry is ``(task_id, assignee,
|
|
current_running_count)``. NOT an operator-actionable failure — the task will be picked up on a
|
|
subsequent tick when the assignee has capacity. Separate bucket so telemetry / dashboards can show "this
|
|
profile is busy" vs
|
|
the board's dispatch lock (issue #35240). A losing dispatcher does no DB writes this tick — the lock
|
|
holder is making progress on the same board. This is the steady-state signal that a single-writer guard
|
|
is
|
|
"""
|
|
|
|
reclaimed: int = 0
|
|
promoted: int = 0
|
|
reconciled_orphans: list[str] = field(default_factory=list)
|
|
"""``running`` cards requeued by :func:`reconcile_orphaned_running` (broken
|
|
claim bookkeeping, dead/gone worker)."""
|
|
reaped_terminal_workers: list[str] = field(default_factory=list)
|
|
"""Task ids whose worker outlived its closed run and was terminated by
|
|
:func:`reap_terminal_workers`."""
|
|
spawned: list[tuple[str, str, str]] = field(default_factory=list)
|
|
"""``(task_id, assignee, workspace_path)`` triples."""
|
|
skipped_unassigned: list[str] = field(default_factory=list)
|
|
"""Ready task ids with no assignee at all — operator-actionable (usually a
|
|
misfiled task waiting for routing)."""
|
|
auto_assigned_default: list[str] = field(default_factory=list)
|
|
"""Unassigned task ids that had ``kanban.default_assignee`` applied this
|
|
tick before spawning, so telemetry/CLI/dashboard can show the dispatcher
|
|
acting on the fallback rule rather than explicit assignments."""
|
|
skipped_nonspawnable: list[str] = field(default_factory=list)
|
|
"""Ready task ids whose assignee names a control-plane lane (e.g. a Claude
|
|
Code terminal like ``orion-cc``), not a Hermes profile. Expected steady-state
|
|
on multi-lane setups, NOT operator-actionable; tracked apart so health
|
|
telemetry can tell "stuck" from "correctly idle"."""
|
|
skipped_per_profile_capped: list[tuple[str, str, int]] = field(default_factory=list)
|
|
"""``(task_id, assignee, current_running_count)`` deferred because the
|
|
assignee is at ``kanban.max_in_progress_per_profile``. Picked up on a later
|
|
tick; separate bucket so dashboards show "profile busy" vs "stuck"."""
|
|
crashed: list[str] = field(default_factory=list)
|
|
"""Task ids reclaimed because their worker PID disappeared."""
|
|
auto_blocked: list[str] = field(default_factory=list)
|
|
"""Task ids auto-blocked by the spawn-failure circuit breaker."""
|
|
timed_out: list[str] = field(default_factory=list)
|
|
"""Task ids whose workers exceeded ``max_runtime_seconds``."""
|
|
stale: list[str] = field(default_factory=list)
|
|
"""Task ids reclaimed for no heartbeat within ``dispatch_stale_timeout_seconds``."""
|
|
respawn_guarded: list[tuple[str, str]] = field(default_factory=list)
|
|
"""``(task_id, reason)`` skipped by the respawn guard: ``"blocker_auth"``
|
|
(quota/auth error — also auto-blocked), ``"recent_success"`` (completed run
|
|
within guard window), ``"active_pr"`` (GitHub PR URL in a recent comment)."""
|
|
rate_limited: list[str] = field(default_factory=list)
|
|
"""Task ids whose workers bailed on a provider rate-limit / quota wall
|
|
(EX_TEMPFAIL sentinel exit) and were released to ``ready`` WITHOUT counting
|
|
a failure — a long quota window must never trip the circuit breaker."""
|
|
skipped_locked: bool = False
|
|
"""True when another process held the board's dispatch lock: this tick did
|
|
no DB writes; the lock holder is making progress on the same board."""
|
|
memory_pressure: Optional[str] = None
|
|
"""Memory pressure that restricted this tick: ``"critical"`` (no new
|
|
workers), ``"elevated"`` (at most one), ``None`` (no restriction).
|
|
Reclaim/promotion bookkeeping still ran; deferred tasks stay queued."""
|
|
|
|
|
|
def describe_suppression(results: Iterable[Optional["DispatchResult"]]) -> str:
|
|
"""One line naming why the tick(s) held ready work back, or ``""``.
|
|
|
|
``active_pr=1, recent_success=2, rate_limited=1, skipped_locked=1,
|
|
memory_pressure=critical`` — the respawn-guard reasons counted per task
|
|
plus the tick-level holds. Feeds the "dispatcher stuck" warnings of the
|
|
CLI daemon and the embedded gateway dispatcher, which otherwise report a
|
|
bare zero-spawn count while ``hermes kanban tail`` is the only place the
|
|
guard reason is written (#111910).
|
|
"""
|
|
counts: dict[str, int] = {}
|
|
pressure: Optional[str] = None
|
|
for res in results:
|
|
if res is None:
|
|
continue
|
|
for _task_id, reason in res.respawn_guarded:
|
|
counts[reason] = counts.get(reason, 0) + 1
|
|
if res.rate_limited:
|
|
counts["rate_limited"] = counts.get("rate_limited", 0) + len(res.rate_limited)
|
|
if res.skipped_locked:
|
|
counts["skipped_locked"] = counts.get("skipped_locked", 0) + 1
|
|
if res.memory_pressure:
|
|
pressure = res.memory_pressure
|
|
parts = [f"{k}={v}" for k, v in sorted(counts.items())]
|
|
if pressure:
|
|
parts.append(f"memory_pressure={pressure}")
|
|
return ", ".join(parts)
|
|
|
|
|
|
# Bounded registry of recently-reaped worker exits, filled by the reap loop in
|
|
# ``dispatch_once`` and read by ``detect_crashed_workers`` to classify a dead-pid
|
|
# task. Entry: ``pid -> (raw_wait_status, reaped_at_epoch)``; raw status kept so
|
|
# both WIFEXITED/WEXITSTATUS and WIFSIGNALED can be consulted. Trimmed by age
|
|
# plus a total size cap. Process-local by nature (``waitpid`` only reaps our own
|
|
# children): a per-tick ``hermes kanban dispatch`` process finds it empty, so
|
|
# ``_classify_dead_worker_exit`` falls back to the exit trailer the worker
|
|
# leaves in its own log (``KANBAN_WORKER_EXIT_TRAILER``).
|
|
_RECENT_WORKER_EXIT_TTL_SECONDS = 600
|
|
_RECENT_WORKER_EXITS_MAX = 4096
|
|
_recent_worker_exits: "dict[int, tuple[int, float]]" = {}
|
|
|
|
# Windows has no ``waitpid(-1)``: a child's exit code is only recoverable
|
|
# through a live handle, so ``_default_spawn`` parks each worker's ``Popen``
|
|
# here (Windows only) and ``reap_worker_zombies`` polls it. Entry: ``pid -> Popen``.
|
|
_live_worker_procs: "dict[int, subprocess.Popen]" = {}
|
|
|
|
|
|
def _wait_status_from_returncode(returncode: int) -> int:
|
|
"""Encode a ``Popen.returncode`` in the wait-status layout the registry stores."""
|
|
return (int(returncode) & 0xFF) << 8
|
|
|
|
|
|
def _record_worker_exit(pid: int, raw_status: int) -> None:
|
|
"""Record a reaped child's exit status; duplicate pids overwrite (latest wins)."""
|
|
if not pid or pid <= 0:
|
|
return
|
|
now = time.time()
|
|
_recent_worker_exits[int(pid)] = (int(raw_status), now)
|
|
if len(_recent_worker_exits) > _RECENT_WORKER_EXITS_MAX // 2:
|
|
cutoff = now - _RECENT_WORKER_EXIT_TTL_SECONDS
|
|
for _pid in [p for p, (_s, t) in _recent_worker_exits.items() if t < cutoff]:
|
|
_recent_worker_exits.pop(_pid, None)
|
|
if len(_recent_worker_exits) > _RECENT_WORKER_EXITS_MAX:
|
|
# Drop oldest half.
|
|
ordered = sorted(_recent_worker_exits.items(), key=lambda kv: kv[1][1])
|
|
for _pid, _ in ordered[: len(ordered) // 2]:
|
|
_recent_worker_exits.pop(_pid, None)
|
|
|
|
|
|
def _classify_worker_exit(pid: int) -> "tuple[str, Optional[int]]":
|
|
"""``(kind, code)`` for a reaped worker PID: ``clean_exit`` (rc 0 while
|
|
still ``running`` = protocol violation), ``rate_limited``
|
|
(``KANBAN_RATE_LIMIT_EXIT_CODE``, never counts as a failure),
|
|
``nonzero_exit``, ``signaled`` (``code`` is the signal), ``unknown`` (pid
|
|
not in the reap registry; ``code`` None)."""
|
|
entry = _recent_worker_exits.get(int(pid))
|
|
if entry is None:
|
|
return ("unknown", None)
|
|
raw, _ = entry
|
|
# Bit-level POSIX wait-status decode instead of os.WIFEXITED/WEXITSTATUS/
|
|
# WIFSIGNALED/WTERMSIG: those helpers do not exist on Windows, where the
|
|
# registry is fed by reap_worker_zombies' Popen poll. Low 7 bits = signal
|
|
# (0 = normal exit, 0x7F = stopped), bits 8-15 = exit code.
|
|
raw = int(raw)
|
|
signal_number = raw & 0x7F
|
|
if signal_number == 0:
|
|
return _exit_code_kind((raw >> 8) & 0xFF)
|
|
if signal_number != 0x7F:
|
|
return ("signaled", signal_number)
|
|
return ("unknown", None)
|
|
|
|
|
|
def _exit_code_kind(code: int) -> "tuple[str, int]":
|
|
"""``(kind, code)`` for a worker's exit code, however it was observed."""
|
|
if code == 0:
|
|
return ("clean_exit", 0)
|
|
if code == _kb.KANBAN_RATE_LIMIT_EXIT_CODE:
|
|
return ("rate_limited", code)
|
|
if code == _kb.KANBAN_TERMINAL_PROVIDER_EXIT_CODE:
|
|
return ("terminal_provider", code)
|
|
return ("nonzero_exit", code)
|
|
|
|
|
|
_EXIT_TRAILER_RE = re.compile(
|
|
r"^" + re.escape(KANBAN_WORKER_EXIT_TRAILER) + r"(\d+)\s*$", re.MULTILINE,
|
|
)
|
|
|
|
|
|
def _worker_log_exit_code(task_id: str, board: Optional[str] = None) -> Optional[int]:
|
|
"""Exit code from the trailer the worker CLI wrote to its own log; None when absent.
|
|
|
|
The durable twin of ``_recent_worker_exits``: written by the worker itself
|
|
(``hermes_cli.quiet_single_query.exit_single_query``), so it is there whether
|
|
or not the process running this sweep ever reaped the worker. Last trailer
|
|
wins — the log is append-mode across re-runs.
|
|
"""
|
|
try:
|
|
raw = _kb.read_worker_log(task_id, tail_bytes=4000, board=board)
|
|
except Exception:
|
|
return None
|
|
matches = _EXIT_TRAILER_RE.findall(raw or "")
|
|
return int(matches[-1]) if matches else None
|
|
|
|
|
|
def reap_worker_zombies() -> "list[int]":
|
|
"""Reap exited workers without blocking; returns reaped PIDs. POSIX reaps
|
|
every child via ``waitpid(-1)``; Windows polls the ``Popen`` handles
|
|
parked by ``_default_spawn`` (the only way to learn a child's exit code
|
|
there), so the rate-limit sentinel exit is classified on both hosts."""
|
|
reaped: "list[int]" = []
|
|
if _kb._IS_WINDOWS:
|
|
for pid, proc in list(_live_worker_procs.items()):
|
|
returncode = proc.poll()
|
|
if returncode is None:
|
|
continue
|
|
_record_worker_exit(pid, _wait_status_from_returncode(returncode))
|
|
_live_worker_procs.pop(pid, None)
|
|
reaped.append(pid)
|
|
return reaped
|
|
try:
|
|
while True:
|
|
try:
|
|
pid, status = os.waitpid(-1, os.WNOHANG)
|
|
except ChildProcessError:
|
|
break
|
|
if pid == 0:
|
|
break
|
|
_record_worker_exit(pid, status)
|
|
reaped.append(pid)
|
|
except Exception:
|
|
pass
|
|
return reaped
|
|
|
|
|
|
def _pid_alive(pid: Optional[int]) -> bool:
|
|
"""Return True if ``pid`` is still running on this host.
|
|
|
|
Uses ``gateway.status._pid_exists`` (OpenProcess on Windows, ``os.kill(pid, 0)``
|
|
on POSIX). **DO NOT** call ``os.kill(pid, 0)`` directly on Windows — there
|
|
``sig=0`` is ``CTRL_C_EVENT`` broadcast to the console group, potentially
|
|
killing unrelated processes.
|
|
|
|
Zombies (exited, not yet reaped) still pass the existence check, so a
|
|
worker would look "alive" forever between exit and reap. Linux: peek at
|
|
``/proc/<pid>/status`` and treat ``State: Z`` as dead; macOS: ask ``ps``
|
|
for the BSD ``stat`` field and treat ``Z`` as dead.
|
|
"""
|
|
if not pid or pid <= 0:
|
|
return False
|
|
from gateway.status import _pid_exists
|
|
if not _pid_exists(int(pid)):
|
|
return False
|
|
if sys.platform == "linux":
|
|
try:
|
|
with open(f"/proc/{int(pid)}/status", "r", encoding="utf-8") as f:
|
|
for line in f:
|
|
if line.startswith("State:"):
|
|
# "State:\tZ (zombie)" → dead
|
|
if "Z" in line.split(":", 1)[1]:
|
|
return False
|
|
break
|
|
except (FileNotFoundError, PermissionError, OSError):
|
|
# proc entry gone → already reaped; treat as dead.
|
|
pass
|
|
elif sys.platform == "darwin":
|
|
try:
|
|
proc = subprocess.run(
|
|
["ps", "-o", "stat=", "-p", str(int(pid))],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
text=True, encoding='utf-8', errors='replace',
|
|
timeout=1,
|
|
check=False,
|
|
)
|
|
if proc.returncode != 0:
|
|
return False
|
|
if "Z" in (proc.stdout or "").strip():
|
|
return False
|
|
except (OSError, subprocess.SubprocessError, TimeoutError):
|
|
# If the secondary probe fails, keep the kill(0) answer.
|
|
pass
|
|
return True
|
|
|
|
|
|
# ``worker_started_at`` value for a spawn whose fingerprint could not be captured. Distinct from the
|
|
# NULL legacy row (pre-fingerprint spawn): such a worker is held (its claim is never released beside
|
|
# the live PID) but NEVER signalled — missing process identity is refusal, not permission (#99558).
|
|
UNVERIFIED_WORKER_FINGERPRINT = "unverified"
|
|
|
|
|
|
def _process_fingerprint(pid: int) -> Optional[str]:
|
|
"""Restart-stable identity of a live process: ``"<instantiation epoch>|<start time>"``. The start
|
|
time alone (``/proc/<pid>/stat`` field 22 on Linux) is clock ticks since THIS boot, so a row that
|
|
survives a reboot could match an unrelated process with the same PID and the same tick value;
|
|
``gateway.drain_control.current_instantiation_epoch`` (``boot_id`` + PID-1 start) changes on every
|
|
reboot / container recreate, so the composed value never survives one. ``None`` when unreadable."""
|
|
from gateway.drain_control import current_instantiation_epoch
|
|
from gateway.status import get_process_start_time
|
|
start = get_process_start_time(int(pid))
|
|
if start is None:
|
|
return None
|
|
return f"{current_instantiation_epoch()}|{start}"
|
|
|
|
|
|
def _worker_alive(pid: Optional[int], started_at) -> bool:
|
|
"""True when ``pid`` is live AND is still the worker we spawned. ``started_at`` is the fingerprint
|
|
recorded by ``_set_worker_pid``; after a reboot (or any PID recycle) an unrelated process can own
|
|
the number, so bare existence is never enough to extend a claim or to signal. A legacy row without
|
|
a fingerprint keeps the existence answer: killing it is the pre-fingerprint behaviour and the row is
|
|
rewritten with a fingerprint on its next spawn. An UNVERIFIED spawn also keeps the existence answer
|
|
(a claim is never released beside a possibly-live worker) but ``_terminate_reclaimed_worker``
|
|
refuses to signal it."""
|
|
if not _kb._pid_alive(pid):
|
|
return False
|
|
if started_at == UNVERIFIED_WORKER_FINGERPRINT:
|
|
return True
|
|
return not _pid_recycled(pid, started_at)
|
|
|
|
|
|
def _pid_recycled(pid: Optional[int], started_at) -> bool:
|
|
"""True when a live ``pid`` is NOT the process fingerprinted at spawn (or the fingerprint can no
|
|
longer be read). Signalling it would hit a stranger. ``None`` fingerprint = legacy row, never
|
|
recycled; the UNVERIFIED marker is always foreign. An integer fingerprint (rows written before the
|
|
boot witness was added) compares the start time only."""
|
|
if started_at is None or not pid:
|
|
return False
|
|
if started_at == UNVERIFIED_WORKER_FINGERPRINT:
|
|
return True
|
|
if isinstance(started_at, str) and "|" in started_at:
|
|
return _process_fingerprint(int(pid)) != started_at
|
|
from gateway.status import _start_times_agree, get_process_start_time
|
|
current = get_process_start_time(int(pid))
|
|
if current is None:
|
|
return True
|
|
try:
|
|
return not _start_times_agree(current, started_at)
|
|
except (TypeError, ValueError):
|
|
return True
|
|
|
|
|
|
def _kill_fn(signal_fn) -> Optional[Callable[[int, int], None]]:
|
|
"""``signal_fn`` test hook, else ``os.kill`` when the platform has one."""
|
|
if signal_fn is not None:
|
|
return signal_fn
|
|
return os.kill if hasattr(os, "kill") else None
|
|
|
|
|
|
def _poll_worker_exit(pid: int, started_at: Optional[int] = None) -> bool:
|
|
"""Poll ~5 s (10 x 0.5 s) for ``pid`` to die; True once it is gone."""
|
|
for _ in range(10):
|
|
if not _worker_alive(pid, started_at):
|
|
return True
|
|
time.sleep(0.5)
|
|
return False
|
|
|
|
|
|
def _sigkill(kill, pid: int) -> bool:
|
|
"""Best-effort SIGKILL; True when the signal was delivered."""
|
|
try:
|
|
# signal.SIGKILL doesn't exist on Windows; SIGTERM maps to TerminateProcess.
|
|
kill(int(pid), getattr(signal, "SIGKILL", signal.SIGTERM))
|
|
return True
|
|
except (ProcessLookupError, OSError):
|
|
return False
|
|
|
|
|
|
def _terminate_reclaimed_worker(
|
|
pid: Optional[int],
|
|
claim_lock: Optional[str],
|
|
*,
|
|
signal_fn=None,
|
|
started_at=None,
|
|
) -> dict[str, Any]:
|
|
"""Best-effort host-local worker termination for reclaim paths. ``started_at`` is the spawn-time
|
|
fingerprint: when the live process no longer matches it, the PID was recycled and nothing is
|
|
signalled — the worker is gone, which is what the reclaim wanted (``terminated`` = True). An
|
|
UNVERIFIED spawn (fingerprint capture failed) that is still live is never signalled either, but
|
|
it is reported as surviving (``signal_refused``) so the reclaim holds the claim instead of
|
|
spawning a duplicate beside it."""
|
|
info: dict[str, Any] = {
|
|
"prev_pid": int(pid) if pid else None,
|
|
"host_local": False,
|
|
"termination_attempted": False,
|
|
"terminated": False,
|
|
"sigkill": False,
|
|
}
|
|
if not pid or pid <= 0 or not claim_lock:
|
|
return info
|
|
if not str(claim_lock).startswith(_kb._host_prefix()):
|
|
return info
|
|
info["host_local"] = True
|
|
|
|
kill = _kill_fn(signal_fn)
|
|
if kill is None:
|
|
return info
|
|
if started_at == UNVERIFIED_WORKER_FINGERPRINT:
|
|
# Never signal by bare number: a dead PID is "gone" (reclaim proceeds), a live one is held.
|
|
info["signal_refused"] = True
|
|
info["terminated"] = not _kb._pid_alive(pid)
|
|
return info
|
|
if _kb._pid_alive(pid) and _pid_recycled(pid, started_at):
|
|
info["terminated"] = True
|
|
info["pid_recycled"] = True
|
|
return info
|
|
|
|
info["termination_attempted"] = True
|
|
try:
|
|
kill(int(pid), signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
# Already gone = successful termination. Leaving terminated=False would
|
|
# make the reclaim guard misread a dead worker as alive and defer forever.
|
|
info["terminated"] = True
|
|
return info
|
|
except OSError:
|
|
return info
|
|
|
|
if _poll_worker_exit(pid, started_at):
|
|
info["terminated"] = True
|
|
return info
|
|
if _worker_alive(pid, started_at):
|
|
if not _sigkill(kill, pid):
|
|
return info
|
|
info["sigkill"] = True
|
|
info["terminated"] = not _worker_alive(pid, started_at)
|
|
return info
|
|
|
|
|
|
def reap_terminal_workers(conn: sqlite3.Connection, *, signal_fn=None) -> list[str]:
|
|
"""End host-local workers that outlived their run (issue #111791) — a worker
|
|
that called ``kanban_complete`` and then hung keeps its ``state.db`` sidecar
|
|
fds open and no ``running``-only sweep can see it once ``tasks.worker_pid`` is
|
|
cleared. Keys on the closed ``task_runs`` row's retained pid + spawn
|
|
fingerprint: a legacy row (NULL fingerprint) or a recycled PID is never
|
|
signalled; a pid that is simply gone just has its evidence cleared. A run
|
|
that ended less than ``TERMINAL_WORKER_REAP_GRACE_SECONDS`` ago is left
|
|
alone so a worker still finalising after its own transition is not killed.
|
|
One row's failure (signal, /proc probe) is logged and skips only that row.
|
|
Returns the task ids whose worker was terminated."""
|
|
rows = conn.execute(
|
|
"SELECT id, task_id, worker_pid, worker_started_at, claim_lock FROM task_runs "
|
|
"WHERE ended_at IS NOT NULL AND ended_at <= ? "
|
|
"AND worker_pid IS NOT NULL AND worker_started_at IS NOT NULL",
|
|
(int(time.time()) - TERMINAL_WORKER_REAP_GRACE_SECONDS,),
|
|
).fetchall()
|
|
host_prefix = _kb._host_prefix()
|
|
reaped: list[str] = []
|
|
for row in rows:
|
|
try:
|
|
_reap_terminal_worker_row(conn, row, host_prefix, signal_fn, reaped)
|
|
except Exception:
|
|
_kb._log.debug(
|
|
"kanban dispatch: terminal worker reap failed for run %s (task %s)",
|
|
row["id"], row["task_id"], exc_info=True,
|
|
)
|
|
return reaped
|
|
|
|
|
|
def _reap_terminal_worker_row(conn, row, host_prefix: str, signal_fn, reaped: list[str]) -> None:
|
|
pid, fingerprint = int(row["worker_pid"]), row["worker_started_at"]
|
|
if pid == os.getpid() or not str(row["claim_lock"] or "").startswith(host_prefix):
|
|
return
|
|
if fingerprint == UNVERIFIED_WORKER_FINGERPRINT and _kb._pid_alive(pid):
|
|
return # unproven identity: never signalled; its evidence is cleared once the pid is gone
|
|
alive = _worker_alive(pid, fingerprint)
|
|
termination = None
|
|
if alive:
|
|
termination = _terminate_reclaimed_worker(
|
|
pid, row["claim_lock"], signal_fn=signal_fn, started_at=fingerprint)
|
|
if not termination["terminated"]:
|
|
return # still alive: try again next tick
|
|
with _kb.write_txn(conn):
|
|
conn.execute(
|
|
"UPDATE task_runs SET worker_pid = NULL, worker_started_at = NULL "
|
|
"WHERE id = ? AND worker_pid = ? AND worker_started_at = ?",
|
|
(row["id"], pid, fingerprint),
|
|
)
|
|
if alive:
|
|
_kb._append_event(
|
|
conn, row["task_id"], "terminal_worker_reaped",
|
|
{"pid": pid, "worker_started_at": fingerprint, **termination}, run_id=row["id"],
|
|
)
|
|
if alive:
|
|
reaped.append(row["task_id"])
|
|
|
|
|
|
def _worker_survived_termination(termination: dict) -> bool:
|
|
"""True when we tried to kill our own host-local worker and it is still alive.
|
|
|
|
Reclaiming then would release the claim and spawn a second worker while the
|
|
first still runs — the duplication loop. Only host-local workers we actually
|
|
signalled count; a non-local lock or no-op attempt (no ``os.kill``) must fall
|
|
through to the normal release path since we cannot manage that worker anyway.
|
|
"""
|
|
return bool(
|
|
termination.get("host_local")
|
|
and (termination.get("termination_attempted") or termination.get("signal_refused"))
|
|
and not termination.get("terminated")
|
|
)
|
|
|
|
|
|
def _defer_reclaim_for_live_worker(
|
|
conn: sqlite3.Connection,
|
|
task_id: str,
|
|
claim_lock: Optional[str],
|
|
now: int,
|
|
termination: dict,
|
|
*,
|
|
reason: str,
|
|
) -> None:
|
|
"""Hold a claim whose worker survived termination instead of releasing it.
|
|
|
|
Extends ``claim_expires`` by ``RECLAIM_DEFER_GRACE_SECONDS`` so the task
|
|
stays ``running`` (no duplicate spawn) and records ``reclaim_deferred``.
|
|
The next tick retries the kill; not spawning a duplicate is what lets the
|
|
throttled worker finally die.
|
|
"""
|
|
grace = now + _kb.RECLAIM_DEFER_GRACE_SECONDS
|
|
with _kb.write_txn(conn):
|
|
cur = conn.execute(
|
|
"UPDATE tasks SET claim_expires = ? "
|
|
"WHERE id = ? AND status = 'running' AND claim_lock IS ?",
|
|
(grace, task_id, claim_lock),
|
|
)
|
|
if cur.rowcount != 1:
|
|
return
|
|
run_id = _kb._current_run_id(conn, task_id)
|
|
if run_id is not None:
|
|
conn.execute("UPDATE task_runs SET claim_expires = ? WHERE id = ?", (grace, run_id))
|
|
payload = {"reason": reason, "claim_lock": claim_lock, "claim_expires_now": grace}
|
|
payload.update(termination)
|
|
_kb._append_event(conn, task_id, "reclaim_deferred", payload, run_id=run_id)
|
|
|
|
|
|
def heartbeat_worker(
|
|
conn: sqlite3.Connection,
|
|
task_id: str,
|
|
*,
|
|
note: Optional[str] = None,
|
|
expected_run_id: Optional[int] = None,
|
|
) -> bool:
|
|
"""Record a ``heartbeat`` event + touch ``last_heartbeat_at``.
|
|
|
|
Liveness signal orthogonal to the PID check: a worker whose forked child
|
|
(train loop, crawl) is stuck can still have a live Python process.
|
|
Returns False if the task is not running or its claim expired.
|
|
"""
|
|
now = int(time.time())
|
|
with _kb.write_txn(conn):
|
|
sql = "UPDATE tasks SET last_heartbeat_at = ? WHERE id = ? AND status = 'running'"
|
|
params: tuple = (now, task_id)
|
|
if expected_run_id is not None:
|
|
sql += " AND current_run_id = ?"
|
|
params += (int(expected_run_id),)
|
|
cur = conn.execute(sql, params)
|
|
if cur.rowcount != 1:
|
|
return False
|
|
run_id = (
|
|
int(expected_run_id)
|
|
if expected_run_id is not None
|
|
else _kb._current_run_id(conn, task_id)
|
|
)
|
|
if run_id is not None:
|
|
conn.execute("UPDATE task_runs SET last_heartbeat_at = ? WHERE id = ?", (now, run_id))
|
|
_kb._append_event(
|
|
conn, task_id, "heartbeat",
|
|
{"note": note} if note else None,
|
|
run_id=run_id,
|
|
)
|
|
return True
|
|
|
|
|
|
def enforce_max_runtime(conn: sqlite3.Connection, *, signal_fn=None) -> list[str]:
|
|
"""Terminate workers whose per-task ``max_runtime_seconds`` has elapsed.
|
|
|
|
SIGTERM, short grace, then SIGKILL. Emits ``timed_out`` and restores the
|
|
task's source phase so the next tick re-spawns the same kind of worker —
|
|
unless the circuit breaker already gave up, leaving it blocked. Host-local
|
|
only (same reasoning as ``detect_crashed_workers``). ``signal_fn`` is a test hook.
|
|
"""
|
|
timed_out: list[str] = []
|
|
now = int(time.time())
|
|
host_prefix = _kb._host_prefix()
|
|
|
|
rows = conn.execute(
|
|
"SELECT t.id, t.worker_pid, t.worker_started_at, "
|
|
" COALESCE(r.started_at, t.started_at) AS active_started_at, "
|
|
" t.max_runtime_seconds, t.claim_lock "
|
|
"FROM tasks t "
|
|
"LEFT JOIN task_runs r ON r.id = t.current_run_id "
|
|
"WHERE t.status = 'running' AND t.max_runtime_seconds IS NOT NULL "
|
|
" AND COALESCE(r.started_at, t.started_at) IS NOT NULL "
|
|
" AND t.worker_pid IS NOT NULL"
|
|
).fetchall()
|
|
for row in rows:
|
|
lock = row["claim_lock"] or ""
|
|
if not lock.startswith(host_prefix):
|
|
continue
|
|
# Runtime is per attempt: ``tasks.started_at`` records the FIRST start,
|
|
# so retries must be measured from the active task_runs row.
|
|
elapsed = now - int(row["active_started_at"])
|
|
limit = int(row["max_runtime_seconds"])
|
|
if elapsed < limit:
|
|
continue
|
|
|
|
pid = int(row["worker_pid"])
|
|
tid = row["id"]
|
|
started_at = _kb._row_get(row, "worker_started_at")
|
|
if started_at == UNVERIFIED_WORKER_FINGERPRINT and _kb._pid_alive(pid):
|
|
# Fingerprint capture failed at spawn: we cannot prove this live PID is our worker, so
|
|
# it is neither signalled nor released beside (duplicate). It is reclaimed once it exits.
|
|
_kb._log.warning("kanban: task %s worker pid %s exceeded max runtime but has no verified "
|
|
"identity; not signalled", tid, pid)
|
|
continue
|
|
# SIGTERM then SIGKILL after 5 s grace; workers wanting a cleaner
|
|
# shutdown install their own SIGTERM handler. A recycled PID (fingerprint
|
|
# mismatch) is never signalled: the worker is already gone.
|
|
killed = False
|
|
kill = _kill_fn(signal_fn)
|
|
if kill is not None and not (_kb._pid_alive(pid) and _pid_recycled(pid, started_at)):
|
|
with contextlib.suppress(ProcessLookupError, OSError):
|
|
kill(pid, signal.SIGTERM)
|
|
# Short polling wait — no time.sleep on the write txn.
|
|
_poll_worker_exit(pid, started_at)
|
|
if _worker_alive(pid, started_at):
|
|
killed = _sigkill(kill, pid)
|
|
|
|
error = f"elapsed {int(elapsed)}s > limit {limit}s"
|
|
with _kb.write_txn(conn):
|
|
retry_status = _kb._retry_status_for_run(conn, tid)
|
|
cur = conn.execute(
|
|
"UPDATE tasks SET status = ?, claim_lock = NULL, "
|
|
"claim_expires = NULL, worker_pid = NULL, worker_started_at = NULL, "
|
|
"last_heartbeat_at = NULL "
|
|
"WHERE id = ? AND status = 'running' "
|
|
" AND worker_pid = ? AND claim_lock IS ?",
|
|
(retry_status, tid, pid, row["claim_lock"]),
|
|
)
|
|
if cur.rowcount == 1:
|
|
payload = {
|
|
"pid": pid,
|
|
"elapsed_seconds": int(elapsed),
|
|
"limit_seconds": limit,
|
|
"sigkill": killed,
|
|
"retry_status": retry_status,
|
|
}
|
|
run_id = _kb._end_run(
|
|
conn, tid, outcome="timed_out", status="timed_out",
|
|
error=error, metadata=payload,
|
|
)
|
|
_kb._append_event(conn, tid, "timed_out", payload, run_id=run_id)
|
|
timed_out.append(tid)
|
|
# Outside the write_txn above because ``_record_task_failure`` opens its
|
|
# own. If the breaker trips this flips the task to ``blocked`` and emits
|
|
# ``gave_up`` on top of the ``timed_out`` already emitted.
|
|
if cur.rowcount == 1:
|
|
_record_task_failure(
|
|
conn, tid,
|
|
error=error,
|
|
outcome="timed_out",
|
|
release_claim=False,
|
|
end_run=False,
|
|
event_payload_extra={"pid": pid, "sigkill": killed, "retry_status": retry_status},
|
|
)
|
|
return timed_out
|
|
|
|
|
|
# A running task with no heartbeat for this long is inactive regardless of
|
|
# ``dispatch_stale_timeout_seconds`` (spec: ">4h started + no commits in 1h").
|
|
_STALE_HEARTBEAT_GAP_SECONDS = 3600
|
|
|
|
|
|
def detect_stale_running(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
stale_timeout_seconds: int = 0,
|
|
signal_fn=None,
|
|
) -> list[str]:
|
|
"""Reclaim ``running`` tasks with no heartbeat progress; returns their ids.
|
|
|
|
Stale = running longer than ``stale_timeout_seconds`` (active run's
|
|
``started_at``, else ``tasks.started_at``) AND ``last_heartbeat_at`` NULL or
|
|
older than ``_STALE_HEARTBEAT_GAP_SECONDS``. Task returns to its source
|
|
phase, run closes ``outcome='stale'``, a live host-local worker is killed.
|
|
``0`` disables the check; ``signal_fn`` is a test hook. Deliberately NOT
|
|
counted via ``_record_task_failure``: an absent heartbeat is not a worker
|
|
failure, and counting it would let long-running tasks trip the breaker.
|
|
"""
|
|
if stale_timeout_seconds <= 0:
|
|
return []
|
|
|
|
now = int(time.time())
|
|
reclaimed: list[str] = []
|
|
|
|
rows = conn.execute(
|
|
"SELECT t.id, t.worker_pid, t.worker_started_at, t.last_heartbeat_at, t.claim_lock, "
|
|
" COALESCE(r.started_at, t.started_at) AS active_started_at "
|
|
"FROM tasks t "
|
|
"LEFT JOIN task_runs r ON r.id = t.current_run_id "
|
|
"WHERE t.status = 'running'"
|
|
).fetchall()
|
|
|
|
for row in rows:
|
|
if row["active_started_at"] is None:
|
|
continue
|
|
elapsed = now - int(row["active_started_at"])
|
|
if elapsed < stale_timeout_seconds:
|
|
continue
|
|
|
|
last_hb = row["last_heartbeat_at"]
|
|
hb_age = (now - int(last_hb)) if last_hb is not None else None
|
|
if hb_age is not None and hb_age < _STALE_HEARTBEAT_GAP_SECONDS:
|
|
continue
|
|
|
|
pid = row["worker_pid"]
|
|
tid = row["id"]
|
|
lock = row["claim_lock"] or ""
|
|
|
|
termination = _kb._terminate_reclaimed_worker(
|
|
pid, lock, signal_fn=signal_fn, started_at=_kb._row_get(row, "worker_started_at"))
|
|
|
|
# Never release a claim while our own worker is still alive: that would
|
|
# spawn a duplicate beside it. Hold the claim and retry next tick.
|
|
if _worker_survived_termination(termination):
|
|
_defer_reclaim_for_live_worker(
|
|
conn, tid, lock, now, termination,
|
|
reason="heartbeat_stale_worker_alive",
|
|
)
|
|
continue
|
|
|
|
with _kb.write_txn(conn):
|
|
retry_status = _kb._retry_status_for_run(conn, tid)
|
|
cur = conn.execute(
|
|
"UPDATE tasks SET status = ?, claim_lock = NULL, "
|
|
"claim_expires = NULL, worker_pid = NULL, worker_started_at = NULL, "
|
|
"last_heartbeat_at = NULL "
|
|
"WHERE id = ? AND status = 'running' "
|
|
" AND claim_lock IS ?",
|
|
(retry_status, tid, row["claim_lock"]),
|
|
)
|
|
if cur.rowcount != 1:
|
|
continue
|
|
|
|
payload = {
|
|
"elapsed_seconds": int(elapsed),
|
|
"last_heartbeat_at": _kb._opt_int(last_hb),
|
|
"heartbeat_age_seconds": _kb._opt_int(hb_age),
|
|
"timeout_seconds": stale_timeout_seconds,
|
|
"pid": int(pid) if pid else None,
|
|
"retry_status": retry_status,
|
|
}
|
|
payload.update(termination)
|
|
|
|
run_id = _kb._end_run(
|
|
conn, tid,
|
|
outcome="stale", status="stale",
|
|
error=(
|
|
f"no heartbeat for {int(hb_age)}s "
|
|
if hb_age is not None
|
|
else "no heartbeat ever"
|
|
) + f" after {int(elapsed)}s running",
|
|
metadata=payload,
|
|
)
|
|
_kb._append_event(conn, tid, "stale", payload, run_id=run_id)
|
|
reclaimed.append(tid)
|
|
|
|
return reclaimed
|
|
|
|
|
|
def reconcile_orphaned_running(conn: sqlite3.Connection) -> list[str]:
|
|
"""Requeue ``running`` cards with broken claim bookkeeping; returns their ids.
|
|
|
|
A task ``running`` with NULL ``claim_lock``/``claim_expires`` (crash
|
|
mid-claim, manual SQL, DB restore) is a zombie forever: ``release_stale_claims``
|
|
needs ``claim_expires``, ``detect_crashed_workers`` needs a host-local lock +
|
|
pid, ``detect_stale_running`` is off by default. Orphans go back to ``ready``
|
|
with a comment, leaked run closed, ``reconciled`` event; a row with a live
|
|
host-local PID is deferred so no duplicate spawns beside it.
|
|
"""
|
|
now = int(time.time())
|
|
reconciled: list[str] = []
|
|
rows = conn.execute(
|
|
"SELECT id, claim_lock, claim_expires, worker_pid, worker_started_at FROM tasks "
|
|
"WHERE status = 'running' "
|
|
" AND (claim_lock IS NULL OR claim_expires IS NULL)"
|
|
).fetchall()
|
|
for row in rows:
|
|
tid = row["id"]
|
|
pid = row["worker_pid"]
|
|
if pid and _worker_alive(pid, _kb._row_get(row, "worker_started_at")):
|
|
# Never requeue beside a live process. Retry next tick.
|
|
_kb._log.debug(
|
|
"kanban reconcile: task %s has broken claim bookkeeping but "
|
|
"pid %s is alive on this host — deferring", tid, pid,
|
|
)
|
|
continue
|
|
with _kb.write_txn(conn):
|
|
cur = conn.execute(
|
|
"UPDATE tasks SET status = 'ready', claim_lock = NULL, "
|
|
"claim_expires = NULL, worker_pid = NULL, worker_started_at = NULL, "
|
|
"last_heartbeat_at = NULL "
|
|
"WHERE id = ? AND status = 'running' "
|
|
" AND claim_lock IS ? AND claim_expires IS ?",
|
|
(tid, row["claim_lock"], row["claim_expires"]),
|
|
)
|
|
if cur.rowcount != 1:
|
|
continue
|
|
payload = {
|
|
"reason": "orphaned_running",
|
|
"claim_lock": row["claim_lock"],
|
|
"claim_expires": _kb._opt_int(row["claim_expires"]),
|
|
"worker_pid": int(pid) if pid else None,
|
|
"now": now,
|
|
}
|
|
run_id = _kb._end_run(
|
|
conn, tid,
|
|
outcome="reclaimed", status="reclaimed",
|
|
error="orphaned running card (broken claim bookkeeping)",
|
|
metadata=payload,
|
|
)
|
|
_kb._insert_comment(
|
|
conn, tid, "dispatcher",
|
|
"reconciliation: card was 'running' with no valid claim "
|
|
"(dead/gone worker) — requeued to ready",
|
|
now,
|
|
)
|
|
_kb._append_event(conn, tid, "reconciled", payload, run_id=run_id)
|
|
reconciled.append(tid)
|
|
_kb._log.info(
|
|
"kanban reconcile: requeued orphaned running task %s "
|
|
"(claim_lock=%r, worker_pid=%r)", tid, row["claim_lock"], pid,
|
|
)
|
|
return reconciled
|
|
|
|
|
|
def _error_fingerprint(error_text: str) -> str:
|
|
"""Normalize an error message (strip PIDs, timestamps) so same-root-cause errors group."""
|
|
fp = re.sub(r'\bpid \d+\b', 'pid N', error_text[:80])
|
|
fp = re.sub(r'\b\d{10,}\b', '<TS>', fp)
|
|
return fp.lower().strip()
|
|
|
|
|
|
# ~96% of "clean exit without a terminal tool call" tasks complete on a later
|
|
# run, so a protocol violation gets a bounded retry before the breaker trips.
|
|
# The budget is a violation-only STREAK (``_protocol_violation_streak``),
|
|
# independent of ``consecutive_failures``: other failure kinds neither consume
|
|
# nor extend it. Per-task ``max_retries`` overrides it.
|
|
_PROTOCOL_VIOLATION_FAILURE_LIMIT = 3
|
|
|
|
# Closed runs to walk when counting the streak; it trips at a handful anyway.
|
|
_PROTOCOL_VIOLATION_SCAN_LIMIT = 50
|
|
|
|
|
|
def _protocol_violation_streak(conn: sqlite3.Connection, task_id: str) -> int:
|
|
"""Count the task's trailing run of clean-exit protocol violations.
|
|
|
|
Walks closed runs newest-first (including the one ``detect_crashed_workers``
|
|
just closed). ``rate_limited`` runs are neutral and skipped (a quota wall
|
|
says nothing about the task); any other closed run breaks the streak, so
|
|
the budget counts ONLY protocol violations. Violations are recognized by the
|
|
``protocol_violation`` run-metadata marker, with the error text as fallback
|
|
for runs recorded before the marker existed.
|
|
"""
|
|
streak = 0
|
|
rows = conn.execute(
|
|
"SELECT outcome, error, metadata FROM task_runs "
|
|
"WHERE task_id = ? AND ended_at IS NOT NULL "
|
|
"ORDER BY id DESC LIMIT ?",
|
|
(task_id, _PROTOCOL_VIOLATION_SCAN_LIMIT),
|
|
).fetchall()
|
|
for row in rows:
|
|
outcome = row["outcome"] or ""
|
|
if outcome == "rate_limited":
|
|
continue
|
|
if outcome == "crashed" and (
|
|
_kb._json_dict(row["metadata"]).get("protocol_violation")
|
|
or "protocol violation" in (row["error"] or "")
|
|
):
|
|
streak += 1
|
|
continue
|
|
break
|
|
return streak
|
|
|
|
|
|
_PROTOCOL_VIOLATION_ERROR = (
|
|
# Worker subprocess returned 0 but its task is still ``running`` in the DB — it exited without calling
|
|
# ``kanban_complete`` / ``kanban_block`` / ``kanban_request_review``. Overwhelmingly the work itself succeeded and only the
|
|
# paperwork was skipped, so a retry usually completes; the corrective sentence below is surfaced to the
|
|
# retry worker via the prior-attempt error in ``build_worker_context`` (guidance approach from #61817).
|
|
# Keep this short: ``_record_task_failure`` caps the stored error at 500 chars and the worker's own
|
|
# last output (``_worker_final_output``, up to 400 chars) is appended after it — a longer preamble
|
|
# truncates away the worker's explanation, which is the part the board and the retry worker need.
|
|
"worker exited cleanly (rc=0) without kanban_complete, kanban_block "
|
|
"or kanban_request_review — protocol violation. "
|
|
"If the prior run already did the work, verify it and "
|
|
"report it via kanban_complete (or kanban_request_review); "
|
|
"a run without a terminal kanban call counts as failed no "
|
|
"matter what it did."
|
|
)
|
|
|
|
|
|
# Rich panel/rule chrome around the rendered response, and the CLI's own preamble lines.
|
|
_LOG_CHROME = re.compile(r"[─━═╭╮╰╯│┃┌┐└┘]+|☤\s*Hermes")
|
|
|
|
|
|
def _exit_summary_marker() -> str:
|
|
"""The CLI exit-summary header (``cli_session_mixin.show_exit_summary``), in the active language."""
|
|
from agent.i18n import t
|
|
return t("cli.session.exit_resume_hint")
|
|
|
|
|
|
def _log_noise_prefixes() -> tuple[str, ...]:
|
|
from agent.i18n import t
|
|
return ("session_id:", "Query:", t("cli.chat.initializing_agent"))
|
|
|
|
|
|
def _worker_final_output(task_id: str, board: Optional[str] = None) -> str:
|
|
"""Best-effort read of a dead worker's last printed text, for the board diagnostic.
|
|
|
|
A ``chat -q`` worker's stdout/stderr are redirected to its per-task log
|
|
(``_default_spawn``), so when it exits without a terminal board call the
|
|
reason is usually sitting there: the model's own explanation of why it could
|
|
not comply (#88603), or the rendered provider error (#46593). The reap used to
|
|
discard it in favour of a canned message on every retry. Trims the CLI exit
|
|
summary, rule lines and the ``session_id:`` trailer; returns "" (never raises)
|
|
on a missing/empty log.
|
|
|
|
``board`` must come from the dispatching tick: ambient current-board resolution
|
|
is wrong for every board but the one the dispatcher thread happens to call
|
|
"current", so the log would silently not be found.
|
|
"""
|
|
try:
|
|
raw = _kb.read_worker_log(task_id, tail_bytes=4000, board=board)
|
|
except Exception:
|
|
return ""
|
|
if not raw:
|
|
return ""
|
|
raw = _EXIT_TRAILER_RE.sub("", raw)
|
|
cut = raw.rfind(_exit_summary_marker())
|
|
if cut != -1:
|
|
raw = raw[:cut]
|
|
lines = []
|
|
for ln in raw.splitlines():
|
|
ln = _LOG_CHROME.sub("", ln).strip()
|
|
if ln and not ln.startswith(_log_noise_prefixes()):
|
|
lines.append(ln)
|
|
return " ".join(lines)[-400:]
|
|
|
|
|
|
@dataclass
|
|
class _DeadWorker:
|
|
"""How ``detect_crashed_workers`` should book one dead worker."""
|
|
|
|
kind: str
|
|
code: Optional[int]
|
|
error_text: str
|
|
event_kind: str
|
|
event_payload: dict
|
|
protocol_violation: bool = False
|
|
rate_limited: bool = False
|
|
terminal_provider: bool = False
|
|
"""``KANBAN_TERMINAL_PROVIDER_EXIT_CODE``: the provider rejected the worker's
|
|
credential/model — trips the breaker on this first occurrence."""
|
|
|
|
@property
|
|
def run_outcome(self) -> str:
|
|
# A rate-limited requeue is recorded as ``rate_limited`` so board history
|
|
# doesn't show a phantom crash for a quota wall.
|
|
return "rate_limited" if self.rate_limited else "crashed"
|
|
|
|
|
|
def _classify_dead_worker(
|
|
pid: int, claimer: Optional[str], *, task_id: Optional[str] = None, board: Optional[str] = None,
|
|
) -> _DeadWorker:
|
|
"""Map a dead worker's reaped exit status to its reclaim bookkeeping.
|
|
|
|
A clean exit or a crash carries the worker's own last output (``worker_output``
|
|
in the event payload, appended to the error text) so the board and the retry
|
|
worker see WHY instead of a bare label; a rate-limited requeue does not need it.
|
|
"""
|
|
dead = _classify_dead_worker_exit(pid, claimer, task_id=task_id, board=board)
|
|
if task_id and not dead.rate_limited:
|
|
worker_output = _worker_final_output(task_id, board=board)
|
|
if worker_output:
|
|
dead.error_text += f" Worker's last output: {worker_output!r}"
|
|
dead.event_payload["worker_output"] = worker_output
|
|
return dead
|
|
|
|
|
|
def _classify_dead_worker_exit(
|
|
pid: int,
|
|
claimer: Optional[str],
|
|
*,
|
|
task_id: Optional[str] = None,
|
|
board: Optional[str] = None,
|
|
) -> _DeadWorker:
|
|
"""Exit status -> reclaim bookkeeping, before the worker's own words are folded in.
|
|
|
|
The reap registry only knows children of THIS process; a per-tick dispatcher
|
|
reads the exit trailer the worker left in its log instead, so the same death
|
|
gets the same booking (protocol violation / rate-limit requeue / crash) as
|
|
under the gateway-embedded dispatcher. A worker that never reached its exit
|
|
epilogue (killed, OOM) leaves no trailer and stays a plain crash.
|
|
"""
|
|
kind, code = _classify_worker_exit(pid)
|
|
if kind == "unknown" and task_id:
|
|
logged = _worker_log_exit_code(task_id, board=board)
|
|
if logged is not None:
|
|
kind, code = _exit_code_kind(logged)
|
|
if kind == "clean_exit":
|
|
# rc=0 while still ``running``: usually the work succeeded and only the
|
|
# paperwork was skipped; the corrective sentence reaches the retry
|
|
# worker via ``build_worker_context``.
|
|
return _DeadWorker(
|
|
kind, code, _PROTOCOL_VIOLATION_ERROR, "protocol_violation",
|
|
# ``protocol_violation`` is the durable marker for
|
|
# _protocol_violation_streak: _end_run copies this payload into the
|
|
# run metadata.
|
|
{"pid": pid, "claimer": claimer, "exit_code": code, "protocol_violation": True},
|
|
protocol_violation=True,
|
|
)
|
|
if kind == "rate_limited":
|
|
# Quota wall — NOT a task failure. Release to the source phase and do
|
|
# NOT count a failure so a long quota window can't trip the breaker.
|
|
return _DeadWorker(
|
|
kind, code,
|
|
f"pid {pid} exited rate-limited (quota wall) — requeued without counting a failure",
|
|
"rate_limited",
|
|
{"pid": pid, "claimer": claimer, "exit_code": code},
|
|
rate_limited=True,
|
|
)
|
|
if kind == "terminal_provider":
|
|
# The worker classified its own provider failure as unhealable (credential
|
|
# revoked, model gone): every further spawn would hit the same wall, so
|
|
# ``_account_crashes`` trips the breaker now instead of after ``failure_limit``.
|
|
return _DeadWorker(
|
|
kind, code,
|
|
f"pid {pid} exited on a terminal provider error (exit {code}): the provider rejected "
|
|
"this profile's credential or model — fix the configuration, then unblock.",
|
|
"crashed",
|
|
{"pid": pid, "claimer": claimer, "exit_kind": kind, "exit_code": code, "terminal_provider": True},
|
|
terminal_provider=True,
|
|
)
|
|
if kind == "nonzero_exit":
|
|
error_text = f"pid {pid} exited with code {code}"
|
|
elif kind == "signaled":
|
|
error_text = f"pid {pid} killed by signal {code}"
|
|
else:
|
|
error_text = f"pid {pid} not alive"
|
|
event_payload = {"pid": pid, "claimer": claimer}
|
|
if code is not None and kind != "unknown":
|
|
event_payload["exit_kind"] = kind
|
|
event_payload["exit_code"] = code
|
|
return _DeadWorker(kind, code, error_text, "crashed", event_payload)
|
|
|
|
|
|
@dataclass
|
|
class _CrashSweep:
|
|
"""Everything ``detect_crashed_workers`` collects inside its reclaim txn."""
|
|
|
|
crashed: list[str] = field(default_factory=list)
|
|
rate_limited: list[str] = field(default_factory=list)
|
|
# ``(task_id, pid, claimer, dead_worker)``: accounted after the txn via
|
|
# ``_record_task_failure`` (needs its own write_txn).
|
|
crash_details: list[tuple[str, int, str, _DeadWorker]] = field(default_factory=list)
|
|
# Worker-exit observer payloads, fired only after every reclaim/accounting
|
|
# txn has committed.
|
|
exited_hook_payloads: list[dict] = field(default_factory=list)
|
|
|
|
|
|
def _reclaim_dead_workers(conn: sqlite3.Connection, board: Optional[str] = None) -> _CrashSweep:
|
|
"""Release every host-local ``running`` task whose worker PID is dead."""
|
|
sweep = _CrashSweep()
|
|
with _kb.write_txn(conn):
|
|
rows = conn.execute(
|
|
"SELECT id, worker_pid, worker_started_at, claim_lock, started_at, assignee "
|
|
"FROM tasks "
|
|
"WHERE status = 'running' AND worker_pid IS NOT NULL"
|
|
).fetchall()
|
|
host_prefix = _kb._host_prefix()
|
|
for row in rows:
|
|
lock = row["claim_lock"] or ""
|
|
if not lock.startswith(host_prefix):
|
|
continue
|
|
# Launch-window grace so a freshly-spawned worker isn't reclaimed
|
|
# before its PID is visible on /proc.
|
|
started_at = _kb._row_get(row, "started_at")
|
|
if started_at is not None and time.time() - started_at < _kb._resolve_crash_grace_seconds():
|
|
continue
|
|
if _worker_alive(row["worker_pid"], _kb._row_get(row, "worker_started_at")):
|
|
continue
|
|
|
|
pid = int(row["worker_pid"])
|
|
dead = _classify_dead_worker(pid, row["claim_lock"], task_id=row["id"], board=board)
|
|
retry_status = _kb._retry_status_for_run(conn, row["id"])
|
|
dead.event_payload["retry_status"] = retry_status
|
|
cur = conn.execute(
|
|
"UPDATE tasks SET status = ?, claim_lock = NULL, "
|
|
"claim_expires = NULL, worker_pid = NULL, worker_started_at = NULL "
|
|
"WHERE id = ? AND status = 'running' "
|
|
" AND worker_pid = ? AND claim_lock IS ?",
|
|
(retry_status, row["id"], pid, row["claim_lock"]),
|
|
)
|
|
if cur.rowcount != 1:
|
|
continue
|
|
run_id = _kb._end_run(
|
|
conn, row["id"],
|
|
outcome=dead.run_outcome, status=dead.run_outcome,
|
|
error=dead.error_text,
|
|
metadata=dict(dead.event_payload),
|
|
)
|
|
_kb._append_event(conn, row["id"], dead.event_kind, dead.event_payload, run_id=run_id)
|
|
sweep.exited_hook_payloads.append({
|
|
"task_id": row["id"],
|
|
"assignee": row["assignee"],
|
|
"run_id": run_id,
|
|
"worker_pid": pid,
|
|
"exit_kind": dead.kind,
|
|
"exit_code": dead.code,
|
|
"outcome": dead.run_outcome,
|
|
"retry_status": retry_status,
|
|
})
|
|
if dead.rate_limited or dead.protocol_violation:
|
|
# Stamp last_failure_error WITHOUT touching ``consecutive_failures``:
|
|
# a rate-limited requeue must show ``check_respawn_guard`` a quota
|
|
# blocker; a below-budget protocol violation never reaches
|
|
# ``_record_task_failure`` (which stamps this column), yet the
|
|
# board UI and retry worker need the corrective message.
|
|
conn.execute(
|
|
"UPDATE tasks SET last_failure_error = ? WHERE id = ?",
|
|
(dead.error_text[:500], row["id"]),
|
|
)
|
|
if dead.rate_limited:
|
|
sweep.rate_limited.append(row["id"])
|
|
else:
|
|
sweep.crashed.append(row["id"])
|
|
sweep.crash_details.append((row["id"], pid, row["claim_lock"], dead))
|
|
return sweep
|
|
|
|
|
|
def _account_crashes(conn: sqlite3.Connection, crash_details: list) -> list[str]:
|
|
"""Count each crash against the breaker; returns the task ids it tripped.
|
|
|
|
Protocol violations get a BOUNDED violation-only budget independent of
|
|
``consecutive_failures`` (per-task ``max_retries`` takes precedence);
|
|
systemic same-error crashes (>= 3 identical fingerprints this tick) and
|
|
terminal provider errors (credential revoked, model gone — a retry cannot
|
|
heal them) trip immediately.
|
|
"""
|
|
auto_blocked: list[str] = []
|
|
fp_counts: dict[str, int] = {}
|
|
for _, _, _, dead in crash_details:
|
|
fp = _error_fingerprint(dead.error_text)
|
|
fp_counts[fp] = fp_counts.get(fp, 0) + 1
|
|
for tid, pid, claimer, dead in crash_details:
|
|
error_text = dead.error_text
|
|
if dead.protocol_violation:
|
|
streak = _protocol_violation_streak(conn, tid)
|
|
trow = conn.execute("SELECT max_retries FROM tasks WHERE id = ?", (tid,)).fetchone()
|
|
if trow is None:
|
|
continue # task deleted mid-loop
|
|
task_override = _kb._row_get(trow, "max_retries")
|
|
violation_limit = (
|
|
int(task_override) if task_override is not None else _PROTOCOL_VIOLATION_FAILURE_LIMIT
|
|
)
|
|
if streak < violation_limit:
|
|
# Below budget: already back at ``ready`` with the error stamped.
|
|
# No ``_record_task_failure`` — must not consume the unified budget.
|
|
continue
|
|
# ``force_trip``: the decision (incl. per-task ``max_retries``) was
|
|
# already made against the violation streak above.
|
|
tripped = _record_task_failure(
|
|
conn, tid,
|
|
error=error_text,
|
|
outcome="crashed",
|
|
failure_limit=violation_limit,
|
|
force_trip=True,
|
|
release_claim=False,
|
|
end_run=False,
|
|
event_payload_extra={
|
|
"pid": pid,
|
|
"claimer": claimer,
|
|
"protocol_violations": streak,
|
|
"protocol_violation_limit": violation_limit,
|
|
},
|
|
)
|
|
elif dead.terminal_provider:
|
|
# A retry cannot heal a revoked credential or a missing model, so
|
|
# the whole ``failure_limit`` budget would be spent on identical
|
|
# failures. ``force_trip`` blocks now, sticky: ``recompute_ready``
|
|
# must not auto-resume it before the operator fixes the provider.
|
|
tripped = _record_task_failure(
|
|
conn, tid,
|
|
error=error_text,
|
|
outcome="crashed",
|
|
force_trip=True,
|
|
release_claim=False,
|
|
end_run=False,
|
|
event_payload_extra={"pid": pid, "claimer": claimer, "terminal_provider": True},
|
|
)
|
|
else:
|
|
is_systemic = fp_counts.get(_error_fingerprint(error_text), 0) >= 3
|
|
extra = {"pid": pid, "claimer": claimer}
|
|
if is_systemic:
|
|
# Trips at 1, below any ``failure_limit``: hold it for an operator.
|
|
extra["sticky"] = True
|
|
tripped = _record_task_failure(
|
|
conn, tid,
|
|
error=error_text,
|
|
outcome="crashed",
|
|
failure_limit=1 if is_systemic else None,
|
|
release_claim=False,
|
|
end_run=False,
|
|
event_payload_extra=extra,
|
|
)
|
|
if tripped:
|
|
auto_blocked.append(tid)
|
|
return auto_blocked
|
|
|
|
|
|
def detect_crashed_workers(conn: sqlite3.Connection, board: Optional[str] = None) -> list[str]:
|
|
"""Reclaim ``running`` tasks whose worker PID is no longer alive.
|
|
|
|
Restores the source phase immediately (no waiting for the claim TTL), for
|
|
tasks claimed by *this host* only — other hosts' PIDs are meaningless.
|
|
Clean exit while ``running`` is a protocol violation with a bounded
|
|
violation-only retry budget; ``KANBAN_RATE_LIMIT_EXIT_CODE`` is a quota
|
|
wall, released WITHOUT counting a failure and surfaced via the
|
|
``_last_rate_limited`` attribute (the return stays crashed-only).
|
|
"""
|
|
sweep = _reclaim_dead_workers(conn, board=board)
|
|
# Outside the main txn: account each crash and maybe trip the breaker.
|
|
auto_blocked = _account_crashes(conn, sweep.crash_details) if sweep.crash_details else []
|
|
# Side-channel attributes keep the public ``list[str]`` return stable;
|
|
# ``dispatch_once`` reads them to populate ``DispatchResult``. Rate-limited
|
|
# requeues did NOT count a failure and are NOT crashes.
|
|
detect_crashed_workers._last_auto_blocked = auto_blocked # type: ignore[attr-defined]
|
|
detect_crashed_workers._last_rate_limited = sweep.rate_limited # type: ignore[attr-defined]
|
|
# Fired only now, after the reclaim txn AND breaker accounting have
|
|
# committed, so subscribers always observe fully durable board state.
|
|
if sweep.exited_hook_payloads and _kb._kanban_observer_consumed("on_kanban_worker_exited"):
|
|
_board = _kb.get_current_board()
|
|
for hook_fields in sweep.exited_hook_payloads:
|
|
hook_fields = dict(hook_fields)
|
|
_kb._fire_kanban_lifecycle_hook(
|
|
# Kanban worker-lifecycle, task-mutation, and dispatcher-tick observers (RFC #58548,
|
|
# accepted as the design basis in the #64231 batch disposition; on_kanban_dispatch_tick is
|
|
# the re-port of PR #56066). All five are observers only: return values are ignored, and
|
|
# every fire site is fully best-effort, so a broken callback can never break dispatch or a
|
|
# task mutation. Cost rule: every call site short-circuits on has_hook(), so when nothing
|
|
# subscribes no payload is built and the hot paths (each dispatcher tick, each task write)
|
|
# pay one dict probe. WHICH PROCESS: worker spawn/exit/stale-claim and the dispatch tick
|
|
# fire in the DISPATCHER process (gateway-embedded dispatcher or ``hermes kanban
|
|
# dispatch``); on_kanban_task_updated fires in whichever process committed the mutation
|
|
# (CLI, worker, or the gateway-embedded dashboard API). Common kwargs (task-scoped hooks):
|
|
# task_id: str, profile_name: str, board: str | None, assignee: str | None, run_id: int |
|
|
# None. on_kanban_worker_spawned fires after ``spawn_fn`` returns AND the worker PID (when
|
|
# one was reported) is durably persisted, per the RFC timing contract; like
|
|
# kanban_task_claimed it runs inside the board's dispatch lock, so callbacks must stay fast.
|
|
# Adds: worker_pid: int | None, workspace_path: str. Privacy: workspace_path is a filesystem
|
|
# path and may reveal project layout or usernames.
|
|
"on_kanban_worker_exited",
|
|
hook_fields.pop("task_id"),
|
|
board=_board,
|
|
**hook_fields,
|
|
)
|
|
return sweep.crashed
|
|
|
|
|
|
def _record_task_failure(
|
|
conn: sqlite3.Connection,
|
|
task_id: str,
|
|
error: str,
|
|
*,
|
|
outcome: str,
|
|
failure_limit: int = None,
|
|
force_trip: bool = False,
|
|
release_claim: bool = False,
|
|
end_run: bool = False,
|
|
event_payload_extra: Optional[dict] = None,
|
|
infrastructure: bool = False,
|
|
) -> bool:
|
|
"""Record a non-success outcome and maybe trip the circuit breaker; every
|
|
non-success path funnels through here so ``consecutive_failures`` stays
|
|
consistent. Returns True when the task was auto-blocked.
|
|
|
|
``release_claim=True, end_run=True``: spawn-failure path (task still
|
|
running with an open run — restore source phase or ``blocked``, release
|
|
claim, close run). Both False: timeout/crash path (caller already restored
|
|
the phase and closed the run; only the counter moves, a trip flips to
|
|
``blocked`` + ``gave_up``). Threshold: per-task ``max_retries`` >
|
|
``failure_limit`` > ``DEFAULT_FAILURE_LIMIT``. ``force_trip`` trips
|
|
unconditionally (caller applied its own bounded-retry policy).
|
|
|
|
``infrastructure=True``: the host refused the spawn (no restart-safe scope,
|
|
#114720) — nothing about the card ran, so the run and event are recorded
|
|
with ``infrastructure: true`` but ``consecutive_failures`` is left alone and
|
|
the breaker never trips; the card stays retryable and
|
|
:func:`check_respawn_guard` spaces the retries.
|
|
"""
|
|
if failure_limit is None:
|
|
failure_limit = DEFAULT_FAILURE_LIMIT
|
|
error = error[:500]
|
|
with _kb.write_txn(conn):
|
|
row = conn.execute(
|
|
"SELECT consecutive_failures, status, max_retries, current_run_id "
|
|
"FROM tasks WHERE id = ?", (task_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return False
|
|
retry_status = (
|
|
_kb._retry_status_for_run(conn, task_id, row["current_run_id"])
|
|
if release_claim
|
|
else ("review" if row["status"] == "review" else "ready")
|
|
)
|
|
failures = int(row["consecutive_failures"]) + (0 if infrastructure else 1)
|
|
|
|
# Per-task override wins over caller-supplied and default thresholds.
|
|
task_override = _kb._row_get(row, "max_retries")
|
|
if task_override is not None:
|
|
effective_limit, limit_source = int(task_override), "task"
|
|
else:
|
|
effective_limit, limit_source = int(failure_limit), "dispatcher"
|
|
|
|
if infrastructure or not (force_trip or failures >= effective_limit):
|
|
if release_claim:
|
|
# Spawn path: restore the claimed source phase + clear claim.
|
|
conn.execute(
|
|
"UPDATE tasks SET status = ?, claim_lock = NULL, "
|
|
"claim_expires = NULL, worker_pid = NULL, worker_started_at = NULL, "
|
|
"consecutive_failures = ?, last_failure_error = ? "
|
|
"WHERE id = ? AND status = 'running'",
|
|
(retry_status, failures, error, task_id),
|
|
)
|
|
else:
|
|
conn.execute(
|
|
"UPDATE tasks SET consecutive_failures = ?, "
|
|
"last_failure_error = ? WHERE id = ?",
|
|
(failures, error, task_id),
|
|
)
|
|
# Timeout/crash path's caller already emitted its own event.
|
|
if end_run:
|
|
detail = {"failures": failures, "retry_status": retry_status}
|
|
if infrastructure:
|
|
detail["infrastructure"] = True
|
|
run_id = _kb._end_run(
|
|
conn, task_id, outcome=outcome, status=outcome, error=error, metadata=detail,
|
|
)
|
|
_kb._append_event(conn, task_id, outcome, {"error": error, **detail}, run_id=run_id)
|
|
return False
|
|
|
|
# Spawn path (release_claim) is still running and also clears claim
|
|
# state; the timeout/crash path already did.
|
|
conn.execute(
|
|
"UPDATE tasks SET status = 'blocked', "
|
|
+ ("claim_lock = NULL, claim_expires = NULL, worker_pid = NULL, worker_started_at = NULL, "
|
|
if release_claim else "")
|
|
+ "consecutive_failures = ?, last_failure_error = ? "
|
|
"WHERE id = ? AND status IN ('running', 'ready', 'review')",
|
|
(failures, error, task_id),
|
|
)
|
|
payload = {
|
|
"failures": failures,
|
|
"effective_limit": effective_limit,
|
|
"limit_source": limit_source,
|
|
"error": error,
|
|
"trigger_outcome": outcome,
|
|
"retry_status": retry_status,
|
|
}
|
|
run_id = None
|
|
if end_run:
|
|
# Only the spawn path has an open run to close.
|
|
run_id = _kb._end_run(
|
|
conn, task_id, outcome="gave_up", status="gave_up", error=error,
|
|
metadata={
|
|
"failures": failures,
|
|
"trigger_outcome": outcome,
|
|
"effective_limit": effective_limit,
|
|
"limit_source": limit_source,
|
|
"retry_status": retry_status,
|
|
},
|
|
)
|
|
if force_trip:
|
|
# The caller applied its own bounded policy, so the counter cannot
|
|
# judge this block: ``recompute_ready`` holds it for an operator.
|
|
payload["sticky"] = True
|
|
if event_payload_extra:
|
|
payload.update(event_payload_extra)
|
|
_kb._append_event(conn, task_id, "gave_up", payload, run_id=run_id)
|
|
return True
|
|
|
|
|
|
def _set_worker_pid(conn: sqlite3.Connection, task_id: str, pid: int) -> None:
|
|
"""Record the spawned child's pid + its restart-stable fingerprint (``_process_fingerprint``), and
|
|
emit a ``spawned`` event carrying them. The fingerprint is what lets every later liveness/kill
|
|
decision tell OUR worker from a process that recycled the PID after a reboot. A failed capture is
|
|
persisted as ``UNVERIFIED_WORKER_FINGERPRINT``, never NULL: NULL is the legacy pre-fingerprint row
|
|
whose bare-PID kill authority a new spawn must not inherit."""
|
|
started_at = _process_fingerprint(int(pid)) or UNVERIFIED_WORKER_FINGERPRINT
|
|
with _kb.write_txn(conn):
|
|
conn.execute("UPDATE tasks SET worker_pid = ?, worker_started_at = ? WHERE id = ?",
|
|
(int(pid), started_at, task_id))
|
|
run_id = _kb._current_run_id(conn, task_id)
|
|
if run_id is not None:
|
|
conn.execute("UPDATE task_runs SET worker_pid = ?, worker_started_at = ? WHERE id = ?",
|
|
(int(pid), started_at, run_id))
|
|
_kb._append_event(conn, task_id, "spawned", {"pid": int(pid), "started_at": started_at}, run_id=run_id)
|
|
|
|
|
|
def adopt_worker_pid(conn: sqlite3.Connection, task_id: str, run_id: int, pid: int) -> bool:
|
|
"""Worker-side half of ``_set_worker_pid``, run by the worker before its first model call.
|
|
|
|
A dispatcher killed between spawning the worker and ``_set_worker_pid`` leaves the run with no
|
|
pid: no liveness check can see the worker, so a TTL expiry reclaims the card and spawns a second
|
|
worker beside it. The worker fills the missing pid itself (``worker_registered``). False when
|
|
``run_id`` is no longer the card's live run: the card was reclaimed before this worker got here,
|
|
and it must exit without working it."""
|
|
started_at = _process_fingerprint(int(pid)) or UNVERIFIED_WORKER_FINGERPRINT
|
|
with _kb.write_txn(conn):
|
|
row = conn.execute("SELECT status, current_run_id, worker_pid, claim_lock FROM tasks WHERE id = ?",
|
|
(task_id,)).fetchone()
|
|
if row is None or row["status"] != "running" or row["current_run_id"] != int(run_id):
|
|
return False
|
|
# Liveness checks are host-local: a pid from another host (or pid namespace) proves nothing here.
|
|
if row["worker_pid"] is None and (row["claim_lock"] or "").startswith(_kb._host_prefix()):
|
|
conn.execute("UPDATE tasks SET worker_pid = ?, worker_started_at = ? WHERE id = ?",
|
|
(int(pid), started_at, task_id))
|
|
conn.execute("UPDATE task_runs SET worker_pid = ?, worker_started_at = ? WHERE id = ?",
|
|
(int(pid), started_at, int(run_id)))
|
|
_kb._append_event(conn, task_id, "worker_registered", {"pid": int(pid), "started_at": started_at},
|
|
run_id=int(run_id))
|
|
return True
|
|
|
|
|
|
def _clear_failure_counter(conn: sqlite3.Connection, task_id: str) -> None:
|
|
"""Reset the unified consecutive-failures counter.
|
|
|
|
Called from ``complete_task`` on success. NOT called on spawn success: a
|
|
spawn proves the worker could start, not that the run will succeed, so
|
|
timeouts and crashes must accumulate across spawn boundaries.
|
|
"""
|
|
with _kb.write_txn(conn):
|
|
conn.execute(
|
|
"UPDATE tasks SET consecutive_failures = 0, "
|
|
"last_failure_error = NULL WHERE id = ?",
|
|
(task_id,),
|
|
)
|
|
|
|
|
|
def check_respawn_guard(
|
|
conn: sqlite3.Connection, task_id: str, *, lane: str = "ready",
|
|
) -> Optional[str]:
|
|
"""Return a guard reason if ``task_id`` should NOT be re-spawned, else None.
|
|
|
|
Called per ready/review row before any claim attempt. Priority order:
|
|
``"infrastructure_cooldown"`` (latest run is a ``spawn_failed`` the host
|
|
refused — no restart-safe scope — within the cooldown; never counted),
|
|
``"rate_limit_cooldown"`` (latest run ``rate_limited`` within the cooldown;
|
|
checked BEFORE ``blocker_auth`` because the requeue stamps a quota-flavored
|
|
``last_failure_error`` that would otherwise park the task forever — that
|
|
path never increments ``consecutive_failures``), ``"blocker_auth"``
|
|
(quota/auth pattern; the breaker still trips eventually), then for the
|
|
ready lane only ``"recent_success"`` (completed run within the window, unless
|
|
a re-queue event arrived after it — a deliberate re-run) and ``"active_pr"``
|
|
(PR URL in a recent comment; re-spawning risks a duplicate PR — unless a
|
|
handoff event followed the comment: the named profile must work on that
|
|
PR). The review lane skips the last two: they are the *inputs* to a review
|
|
handoff. Stale / dead claim locks are NOT a guard reason — the reclaim
|
|
passes own those.
|
|
"""
|
|
row = conn.execute(
|
|
"SELECT last_failure_error FROM tasks WHERE id = ?",
|
|
(task_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
|
|
now = int(time.time())
|
|
|
|
# 1. Rate-limit cooldown — see docstring for why this precedes blocker_auth.
|
|
# LATEST run only: a newer crash/completion supersedes the rate-limit run.
|
|
# An infrastructure spawn refusal (#114720) shares the cooldown: the host
|
|
# condition is not the card's, so it retries forever, spaced, and never
|
|
# reaches the breaker.
|
|
rl_cooldown = _kb._resolve_rate_limit_cooldown_seconds()
|
|
latest_run = conn.execute(
|
|
"SELECT outcome, ended_at, metadata FROM task_runs "
|
|
"WHERE task_id = ? AND ended_at IS NOT NULL "
|
|
"ORDER BY ended_at DESC LIMIT 1",
|
|
(task_id,),
|
|
).fetchone()
|
|
if latest_run is not None and latest_run["outcome"] == "spawn_failed":
|
|
if rl_cooldown > 0 and _kb._json_dict(latest_run["metadata"]).get("infrastructure"):
|
|
ended_at = latest_run["ended_at"]
|
|
if ended_at is not None and (now - int(ended_at)) < rl_cooldown:
|
|
return "infrastructure_cooldown"
|
|
if latest_run is not None and latest_run["outcome"] == "rate_limited":
|
|
if rl_cooldown <= 0:
|
|
# Cooldown disabled — respawn immediately, skipping blocker_auth so
|
|
# the stamped rate-limit text doesn't re-trap the task.
|
|
return None
|
|
ended_at = latest_run["ended_at"]
|
|
if ended_at is not None and (now - int(ended_at)) < rl_cooldown:
|
|
return "rate_limit_cooldown"
|
|
# Cooldown elapsed — return early so blocker_auth doesn't catch the
|
|
# stamped rate-limit text; this path intentionally retries forever
|
|
# (spaced by the cooldown) until quota returns or a real run supersedes it.
|
|
return None
|
|
|
|
# 2. Quota / auth blocker: retrying immediately will not help. A plain
|
|
# crash is different: its persisted error includes the worker's last
|
|
# captured output, which is context rather than a diagnosis and may contain
|
|
# benign commands such as ``claude auth status`` (#117097).
|
|
err = _kb._lossy_text(row["last_failure_error"])
|
|
latest_outcome = latest_run["outcome"] if latest_run is not None else None
|
|
if err and latest_outcome != "crashed" and _RESPAWN_BLOCKER_RE.search(err):
|
|
return "blocker_auth"
|
|
|
|
# Review-lane spawns stop here: a recent completed run and a fresh PR URL
|
|
# are the canonical *inputs* to a review handoff, not duplicate-work signals.
|
|
if lane == "review":
|
|
return None
|
|
|
|
# 3. Completed run within guard window. Exception: an explicit re-queue
|
|
# AFTER that success (done→ready drag, re-promotion, unblock, reclaim) is
|
|
# a deliberate "run it again" — otherwise a manual done→ready would sit
|
|
# silently held until the window elapses.
|
|
cutoff = now - _RESPAWN_GUARD_SUCCESS_WINDOW
|
|
recent_completed = conn.execute(
|
|
"SELECT ended_at FROM task_runs "
|
|
"WHERE task_id = ? AND outcome = 'completed' AND ended_at >= ? "
|
|
"ORDER BY ended_at DESC LIMIT 1",
|
|
(task_id, cutoff),
|
|
).fetchone()
|
|
if recent_completed:
|
|
completed_at = int(recent_completed["ended_at"] or 0)
|
|
requeued_after = conn.execute(
|
|
"SELECT 1 FROM task_events "
|
|
"WHERE task_id = ? AND created_at >= ? "
|
|
"AND kind IN ('status', 'promoted', 'unblocked', 'reclaimed') "
|
|
"LIMIT 1",
|
|
(task_id, completed_at),
|
|
).fetchone()
|
|
if not requeued_after:
|
|
return "recent_success"
|
|
|
|
# 4. GitHub PR URL in a recent comment — prior worker already opened a PR.
|
|
# Exception: a handoff AFTER the newest PR comment (operator reassign,
|
|
# reviewer changes_requested, review reopen) names the profile that must
|
|
# now work on THAT PR — a closer or the implementer finishing it, not a
|
|
# duplicate implementation (#111910). A crash/reclaim is not a handoff,
|
|
# so the worker that opened the PR is still not re-spawned against it.
|
|
pr_cutoff = now - _RESPAWN_GUARD_PR_WINDOW
|
|
for c in conn.execute(
|
|
"SELECT body, created_at FROM task_comments "
|
|
"WHERE task_id = ? AND created_at >= ? ORDER BY created_at DESC",
|
|
(task_id, pr_cutoff),
|
|
).fetchall():
|
|
body = _kb._lossy_text(c["body"])
|
|
if not (body and _RESPAWN_GUARD_PR_URL_RE.search(body)):
|
|
continue
|
|
events = conn.execute(
|
|
# Strictly after: a same-second tie stays guarded (fail closed).
|
|
"SELECT kind, payload FROM task_events "
|
|
"WHERE task_id = ? AND created_at > ? "
|
|
"AND kind IN ('assigned', 'changes_requested', 'review_reopened')",
|
|
(task_id, int(c["created_at"] or 0)),
|
|
).fetchall()
|
|
if any(_is_handoff_event(e["kind"], e["payload"]) for e in events):
|
|
return None
|
|
return "active_pr"
|
|
|
|
return None
|
|
|
|
|
|
def _is_handoff_event(kind: str, payload: Optional[str]) -> bool:
|
|
"""Only an ``assigned`` event that moves the card to a DIFFERENT profile is
|
|
a handoff. A no-op re-assign (dev→dev via CLI/dashboard/``reassign
|
|
--reclaim``), an unassign, or the dispatcher's own
|
|
``kanban.default_assignee`` write would otherwise lift ``active_pr`` for
|
|
the very implementer that opened the PR. Events without ``from`` (written
|
|
before it was recorded) are not trusted as handoffs — fail closed."""
|
|
if kind != "assigned":
|
|
return True
|
|
data = _kb._json_or(payload, {})
|
|
if not isinstance(data, dict) or data.get("source") == "kanban.default_assignee":
|
|
return False
|
|
to = data.get("assignee")
|
|
return bool(to) and "from" in data and data["from"] != to
|
|
|
|
|
|
def _profile_exists_fn() -> Optional[Callable[[str], bool]]:
|
|
"""``hermes_cli.profiles.profile_exists``, or ``None`` when it cannot be
|
|
imported (local import avoids a cycle; callers fall back to trusting the
|
|
assignee).
|
|
|
|
When ``kanban.dispatch_profiles`` is set (#110995) the returned predicate
|
|
additionally requires the assignee to be listed, fail-closed — so a card
|
|
assigned to ``default`` is only claimable by homes that opted into it.
|
|
Foreign assignees land in the existing ``skipped_nonspawnable`` bucket.
|
|
"""
|
|
try:
|
|
from hermes_cli.profiles import normalize_profile_name, profile_exists
|
|
except Exception:
|
|
return None
|
|
allowlist = _dispatch_profile_allowlist(normalize_profile_name)
|
|
if allowlist is None:
|
|
return profile_exists
|
|
|
|
def _gated(name: str) -> bool:
|
|
try:
|
|
canon = normalize_profile_name(name)
|
|
except ValueError:
|
|
return False
|
|
return canon in allowlist and bool(profile_exists(name))
|
|
|
|
return _gated
|
|
|
|
|
|
def _dispatch_profile_allowlist(normalize_profile_name) -> Optional[frozenset]:
|
|
"""Per-home claim allowlist ``kanban.dispatch_profiles`` (#110995).
|
|
|
|
On a shared board (one ``kanban.db`` mounted across several Hermes homes),
|
|
every home's ``profile_exists`` returns True for ``default`` — the root
|
|
profile every home has — so a card assigned to ``default`` is claimable by
|
|
every home's dispatcher. A home opts out of foreign claims by declaring
|
|
which assignees it may claim::
|
|
|
|
kanban:
|
|
dispatch_profiles: ["sage", "researcher"] # or "sage,researcher"
|
|
|
|
Returns ``None`` only when the key is absent from the user config (upstream
|
|
behavior: any existing profile is claimable). A present value is
|
|
fail-closed: an empty list, ``null`` or a bare ``dispatch_profiles:`` claims
|
|
nothing. The user layer is read without the ``DEFAULT_CONFIG`` merge (whose
|
|
``None`` placeholder would make the key look present in every home), and a
|
|
config read that raises also claims nothing — a corrupt config on a shared
|
|
board must never widen this home's claim scope silently (#113620).
|
|
"""
|
|
try:
|
|
from hermes_cli.config_effective import load_user_config_effective
|
|
kanban = (load_user_config_effective(fail_closed=True) or {}).get("kanban", {})
|
|
except Exception as exc:
|
|
_kb._log.warning(
|
|
"kanban: could not read kanban.dispatch_profiles (%s: %s) — "
|
|
"this home claims no cards until the config is readable",
|
|
type(exc).__name__, exc,
|
|
)
|
|
return frozenset()
|
|
if not isinstance(kanban, Mapping) or "dispatch_profiles" not in kanban:
|
|
return None
|
|
raw = kanban["dispatch_profiles"]
|
|
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
|
_kb._log.warning(
|
|
"kanban: kanban.dispatch_profiles is present but empty — this home "
|
|
"claims no cards; omit the key to allow any existing profile"
|
|
)
|
|
return frozenset()
|
|
names = [str(n) for n in raw] if isinstance(raw, (list, tuple)) else str(raw).split(",")
|
|
allowed = set()
|
|
for n in names:
|
|
try:
|
|
allowed.add(normalize_profile_name(n))
|
|
except ValueError:
|
|
continue
|
|
return frozenset(allowed)
|
|
|
|
|
|
def dispatch_profile_allowlist_summary() -> str:
|
|
"""Human-readable resolution of ``kanban.dispatch_profiles`` for this home.
|
|
|
|
Surfaced by ``hermes kanban diagnostics`` so an operator on a shared board
|
|
can see what a home believes it may claim (#113620): ``any`` (key absent),
|
|
the sorted allowed names, or ``none (fail-closed: ...)``.
|
|
"""
|
|
try:
|
|
from hermes_cli.profiles import normalize_profile_name
|
|
except Exception as exc:
|
|
return f"none (fail-closed: profiles unavailable: {exc})"
|
|
allowlist = _dispatch_profile_allowlist(normalize_profile_name)
|
|
if allowlist is None:
|
|
return "any"
|
|
if allowlist:
|
|
return ", ".join(sorted(allowlist))
|
|
return ("none (fail-closed: kanban.dispatch_profiles is present but names no valid "
|
|
"profile, or the config could not be read — omit the key to allow any)")
|
|
|
|
|
|
def _has_spawnable(conn: sqlite3.Connection, status: str) -> bool:
|
|
rows = conn.execute(
|
|
"SELECT DISTINCT assignee FROM tasks "
|
|
"WHERE status = ? AND assignee IS NOT NULL AND claim_lock IS NULL",
|
|
(status,),
|
|
).fetchall()
|
|
if not rows:
|
|
return False
|
|
profile_exists = _profile_exists_fn()
|
|
if profile_exists is None:
|
|
# Can't introspect — assume spawnable, preserve legacy behavior.
|
|
return True
|
|
return any(profile_exists(row["assignee"]) for row in rows)
|
|
|
|
|
|
def has_spawnable_ready(conn: sqlite3.Connection) -> bool:
|
|
"""True iff a ready+assigned+unclaimed task maps to a real Hermes profile.
|
|
|
|
Lets health telemetry tell "stuck" (``0 spawned`` with spawnable work) from
|
|
"correctly idle" (only control-plane lanes waiting on ``claim_task``). Falls
|
|
back to "any assigned" when ``profile_exists`` is unimportable.
|
|
"""
|
|
return _has_spawnable(conn, "ready")
|
|
|
|
|
|
def has_spawnable_review(conn: sqlite3.Connection) -> bool:
|
|
""":func:`has_spawnable_ready` for the review column."""
|
|
return _has_spawnable(conn, "review")
|
|
|
|
|
|
def review_dispatch_enabled() -> bool:
|
|
"""Whether review tasks dispatch automatically. Default true (Hermes ships
|
|
``sdlc-review``); operators disable it for human-only review boards.
|
|
"""
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
return bool((load_config() or {}).get("kanban", {}).get("review_dispatch", True))
|
|
except Exception:
|
|
return True
|
|
|
|
|
|
# Memory-aware dispatch guard: an uncapped board once OOM'd a 1 GiB host. Two
|
|
# safeguards — a memory-DERIVED default cap when none is configured
|
|
# (``resolve_max_in_progress``) and a live memory-PRESSURE guard inside the
|
|
# tick (``_memory_pressure_level``) because a static cap can't see other
|
|
# tenants. Both fail open: non-Linux / read error → no cap / "unknown".
|
|
|
|
# Assumed per-worker footprint for the derived cap; deliberately conservative
|
|
# so the cap errs toward fewer workers on small VMs.
|
|
MEMORY_GUARD_MB_PER_WORKER = 512
|
|
|
|
# Derived default bounds: never below 2 (smallest VM must still progress),
|
|
# never above 8 (more fan-out must be explicit in config).
|
|
DERIVED_MAX_IN_PROGRESS_FLOOR = 2
|
|
DERIVED_MAX_IN_PROGRESS_CEILING = 8
|
|
|
|
|
|
def _system_memory_sample() -> dict:
|
|
"""Best-effort system memory snapshot (KiB values), ``{}`` when unknown.
|
|
|
|
Local import keeps ``kanban_db`` importable without the gateway package.
|
|
Module-level indirection is also the test seam — conftest patches this to
|
|
``{}`` so results don't depend on the CI runner's live memory.
|
|
"""
|
|
try:
|
|
from gateway.lifecycle_ledger import sample_memory
|
|
return sample_memory() or {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def derive_default_max_in_progress(sample: Optional[Mapping[str, Any]] = None) -> Optional[int]:
|
|
"""Memory-derived default for ``kanban.max_in_progress`` when unset:
|
|
``clamp(MemTotal / MEMORY_GUARD_MB_PER_WORKER, FLOOR, CEILING)``. Returns
|
|
``None`` (no cap) when total memory is unknown, so macOS/Windows dev
|
|
machines are unaffected.
|
|
"""
|
|
if sample is None:
|
|
sample = _system_memory_sample()
|
|
total_kib = sample.get("mem_total_kib")
|
|
if isinstance(total_kib, bool) or not isinstance(total_kib, int) or total_kib <= 0:
|
|
return None
|
|
workers = (total_kib // 1024) // MEMORY_GUARD_MB_PER_WORKER
|
|
return max(DERIVED_MAX_IN_PROGRESS_FLOOR, min(workers, DERIVED_MAX_IN_PROGRESS_CEILING))
|
|
|
|
|
|
def resolve_max_in_progress(configured: Optional[int]) -> Optional[int]:
|
|
"""Effective global concurrency cap: explicit config wins, else the
|
|
memory-derived default. All config-parsing callers route through this so
|
|
both paths agree.
|
|
"""
|
|
if configured is not None:
|
|
return configured
|
|
return derive_default_max_in_progress()
|
|
|
|
|
|
def configured_max_in_progress() -> Optional[int]:
|
|
"""Read ``kanban.max_in_progress`` from config, or None when unset/invalid.
|
|
|
|
Shared so every dispatch entry point agrees on "explicitly configured": a
|
|
positive integer wins, anything else falls through to the derived default.
|
|
"""
|
|
try:
|
|
from hermes_cli.config import load_config_readonly
|
|
raw = (load_config_readonly() or {}).get("kanban", {}).get("max_in_progress")
|
|
except Exception:
|
|
return None
|
|
if raw is None:
|
|
return None
|
|
try:
|
|
ival = int(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return ival if ival >= 1 else None
|
|
|
|
|
|
def count_running_tasks(conn: sqlite3.Connection) -> int:
|
|
"""Number of tasks in ``status='running'``.
|
|
|
|
Used by the multi-board sweep to count OTHER boards' workers against the
|
|
host-level budget — the memory-derived cap bounds the machine, not the
|
|
board. Fails open to 0 so a broken board doesn't brick dispatch on healthy ones.
|
|
"""
|
|
try:
|
|
return int(
|
|
conn.execute(
|
|
"SELECT COUNT(*) FROM tasks WHERE status = 'running'"
|
|
).fetchone()[0]
|
|
)
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
def count_running_tasks_other_boards(board: Optional[str] = None) -> int:
|
|
"""Total ``running`` tasks across every board EXCEPT ``board``.
|
|
|
|
Caps bound the HOST, but each board's tick only sees its own DB; without
|
|
this a derived cap of N gets multiplied by the number of active boards.
|
|
Boards are matched by resolved DB path, so ``HERMES_KANBAN_DB`` (pins every
|
|
board to one file) yields 0. Fails open per board.
|
|
"""
|
|
try:
|
|
current_path = str(_kb.kanban_db_path(board=board).expanduser().resolve())
|
|
except Exception:
|
|
current_path = None
|
|
try:
|
|
boards = _kb.list_boards(include_archived=False)
|
|
except Exception:
|
|
return 0
|
|
total = 0
|
|
for meta in boards:
|
|
slug = meta.get("slug") or _kb.DEFAULT_BOARD
|
|
try:
|
|
path = _kb.kanban_db_path(board=slug).expanduser()
|
|
resolved = str(path.resolve())
|
|
if current_path is not None and resolved == current_path:
|
|
continue
|
|
if not path.exists():
|
|
continue
|
|
other = _kbc.connect(board=slug)
|
|
try:
|
|
total += count_running_tasks(other)
|
|
finally:
|
|
with contextlib.suppress(Exception):
|
|
other.close()
|
|
except Exception:
|
|
continue
|
|
return total
|
|
|
|
|
|
def _memory_pressure_level(sample: Optional[Mapping[str, Any]] = None) -> str:
|
|
"""Classify system memory pressure: ok/elevated/critical/unknown.
|
|
|
|
Reuses :func:`gateway.memory_status.classify_pressure` so "critical" matches
|
|
the dashboard banner and lifecycle-ledger OOM heuristics. ``unknown``
|
|
(non-Linux, read failure) imposes no restriction — never brick dispatch
|
|
where /proc is unavailable.
|
|
"""
|
|
if sample is None:
|
|
sample = _system_memory_sample()
|
|
if not sample:
|
|
return "unknown"
|
|
try:
|
|
from gateway.memory_status import classify_pressure
|
|
return classify_pressure(sample.get("mem_available_kib"), sample.get("mem_total_kib"))
|
|
except Exception:
|
|
return "unknown"
|
|
|
|
|
|
def dispatch_once(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
spawn_fn=None,
|
|
ttl_seconds: Optional[int] = None,
|
|
dry_run: bool = False,
|
|
max_spawn: Optional[int] = None,
|
|
max_in_progress: Optional[int] = None,
|
|
failure_limit: int = DEFAULT_FAILURE_LIMIT,
|
|
stale_timeout_seconds: int = 0,
|
|
board: Optional[str] = None,
|
|
default_assignee: Optional[str] = None,
|
|
max_in_progress_per_profile: Optional[int] = None,
|
|
reconcile_orphans: bool = True,
|
|
) -> DispatchResult:
|
|
"""Run one dispatcher tick under the board's single-writer lock.
|
|
|
|
Wraps :func:`_dispatch_once_locked` in the non-blocking :func:`_dispatch_tick_lock`
|
|
so two dispatchers on one ``kanban.db`` never race a write tick on WAL
|
|
frames. The loser returns an empty ``DispatchResult`` with
|
|
``skipped_locked=True`` and writes nothing; the lock is keyed on the
|
|
resolved DB path so unrelated boards tick in parallel.
|
|
"""
|
|
def _locked_tick() -> DispatchResult:
|
|
return _dispatch_once_locked(
|
|
conn,
|
|
spawn_fn=spawn_fn,
|
|
ttl_seconds=ttl_seconds,
|
|
dry_run=dry_run,
|
|
max_spawn=max_spawn,
|
|
max_in_progress=max_in_progress,
|
|
failure_limit=failure_limit,
|
|
stale_timeout_seconds=stale_timeout_seconds,
|
|
board=board,
|
|
default_assignee=default_assignee,
|
|
max_in_progress_per_profile=max_in_progress_per_profile,
|
|
reconcile_orphans=reconcile_orphans,
|
|
)
|
|
|
|
try:
|
|
db_path = _kb.kanban_db_path(board=board)
|
|
except Exception:
|
|
# Must not lose the tick — fall through to an unguarded dispatch.
|
|
result = _locked_tick()
|
|
_kb._fire_dispatch_tick_hook(result, board=board, dry_run=dry_run)
|
|
return result
|
|
with _kbc._dispatch_tick_lock(db_path) as held:
|
|
if not held:
|
|
result = DispatchResult(skipped_locked=True)
|
|
else:
|
|
result = _locked_tick()
|
|
# Still under the dispatch lock: periodic PASSIVE WAL checkpoint.
|
|
_kbc._maybe_checkpoint_wal(conn, db_path)
|
|
# Lock released. Fire the tick observer strictly OUTSIDE the critical
|
|
# section: a slow subscriber must never stall a sibling dispatcher's tick.
|
|
_kb._fire_dispatch_tick_hook(result, board=board, dry_run=dry_run)
|
|
return result
|
|
|
|
|
|
def _call_spawn_fn(spawn_fn, task: Task, workspace: str, board: Optional[str]) -> Optional[int]:
|
|
"""Back-compat: older spawn_fn signatures (and test stubs) accept only
|
|
``(task, workspace)``; pass ``board`` only when the callable supports it."""
|
|
import inspect
|
|
try:
|
|
sig = inspect.signature(spawn_fn)
|
|
if "board" in sig.parameters:
|
|
return spawn_fn(task, workspace, board=board)
|
|
return spawn_fn(task, workspace)
|
|
except (TypeError, ValueError):
|
|
return spawn_fn(task, workspace)
|
|
|
|
|
|
def _dispatch_lane_task(
|
|
conn: sqlite3.Connection,
|
|
row: sqlite3.Row,
|
|
assignee: str,
|
|
result: "DispatchResult",
|
|
*,
|
|
lane: str,
|
|
dry_run: bool,
|
|
ttl_seconds: Optional[int],
|
|
board: Optional[str],
|
|
failure_limit: int,
|
|
spawn_fn,
|
|
per_profile_cap: Optional[int],
|
|
per_profile_running: dict[str, int],
|
|
) -> bool:
|
|
"""Guard, claim, resolve the workspace and spawn one ready/review row.
|
|
Returns True when a spawn slot was consumed (real or ``dry_run``); every
|
|
skip is recorded on ``result``.
|
|
"""
|
|
task_id = row["id"]
|
|
# Non-profile assignees (control-plane lanes that pull via ``claim_task``)
|
|
# would fail ``hermes -p <assignee>`` at startup and loop ready→crash→ready
|
|
# forever. Bucketed apart from skipped_unassigned: the operator cannot fix
|
|
# it by assigning a profile, and health telemetry suppresses "stuck" for it.
|
|
profile_exists = _profile_exists_fn()
|
|
if profile_exists is not None and not profile_exists(assignee):
|
|
result.skipped_nonspawnable.append(task_id)
|
|
# Per-task diagnostic so ``show``/``tail`` name the missing profile instead of leaving
|
|
# the card in ``ready`` with zero board evidence (#122422). Unlike a respawn guard the
|
|
# condition never expires on its own, so write it once: a repeat only when something
|
|
# else happened on the card since (reassign, comment) — not one row per tick forever,
|
|
# and not one row per foreign home per tick on a shared board (#101015).
|
|
if not dry_run:
|
|
with _kb.write_txn(conn):
|
|
last = conn.execute(
|
|
"SELECT kind, payload FROM task_events WHERE task_id = ? "
|
|
"ORDER BY created_at DESC, id DESC LIMIT 1", (task_id,)).fetchone()
|
|
if (last is None or last["kind"] != "skipped_nonspawnable"
|
|
or last["payload"] != _kb._json_or_null({"assignee": assignee})):
|
|
_kb._append_event(conn, task_id, "skipped_nonspawnable", {"assignee": assignee})
|
|
return False
|
|
# Per-profile cap: one profile's local model / API quota / browser pool
|
|
# must not be overwhelmed by a fan-out even with global headroom.
|
|
if per_profile_cap is not None:
|
|
current = per_profile_running.get(assignee, 0)
|
|
if current >= per_profile_cap:
|
|
result.skipped_per_profile_capped.append((task_id, assignee, current))
|
|
return False
|
|
guard_reason = check_respawn_guard(conn, task_id, lane=lane)
|
|
if guard_reason is not None:
|
|
result.respawn_guarded.append((task_id, guard_reason))
|
|
# Event so ``hermes kanban tail`` shows why the task looks stuck.
|
|
# Honour kanban.default_assignee: when the dispatcher hits an unassigned ready task and an
|
|
# operator-configured fallback exists, persist the assignment and proceed. This removes the
|
|
# dashboard footgun where a task created without an assignee parks in 'ready' forever even though
|
|
# the operator's intent ("default") was perfectly clear (#27145). Mutating the row (not just the
|
|
# in-memory view) keeps diagnostics and the board state consistent: the task is now legitimately
|
|
# owned by ``kanban.default_assignee``, not "unassigned but secretly routed".
|
|
if not dry_run:
|
|
with _kb.write_txn(conn):
|
|
_kb._append_event(conn, task_id, "respawn_guarded", {"reason": guard_reason})
|
|
return False
|
|
|
|
def _count_spawn(name: str) -> None:
|
|
# Later rows in this tick respect the per-profile cap; subsequent
|
|
# ticks re-query from the DB.
|
|
if per_profile_cap is not None and name:
|
|
per_profile_running[name] = per_profile_running.get(name, 0) + 1
|
|
|
|
if dry_run:
|
|
result.spawned.append((task_id, assignee, ""))
|
|
_count_spawn(assignee)
|
|
return True
|
|
claim = _kb.claim_review_task if lane == "review" else _kb.claim_task
|
|
claimed = claim(conn, task_id, ttl_seconds=ttl_seconds)
|
|
if claimed is None:
|
|
return False
|
|
try:
|
|
resolved_branch_name = None
|
|
if claimed.workspace_kind == "worktree":
|
|
workspace, resolved_branch_name = _kbw._resolve_worktree_workspace(claimed, board=board)
|
|
else:
|
|
workspace = _kbw.resolve_workspace(claimed, board=board)
|
|
except Exception as exc:
|
|
if _record_task_failure(
|
|
conn, claimed.id, f"workspace: {exc}",
|
|
outcome="spawn_failed", failure_limit=failure_limit, release_claim=True, end_run=True,
|
|
):
|
|
result.auto_blocked.append(claimed.id)
|
|
return False
|
|
_kbw.set_workspace_path(conn, claimed.id, str(workspace))
|
|
if claimed.workspace_kind == "worktree":
|
|
_kbw.set_branch_name(conn, claimed.id, resolved_branch_name or (claimed.branch_name or "").strip() or f"wt/{claimed.id}")
|
|
_kbw._maybe_emit_scratch_tip(conn, claimed.id, claimed.workspace_kind)
|
|
if lane == "review":
|
|
# Force-load sdlc-review; the kanban lifecycle is already in every
|
|
# worker's system prompt via KANBAN_GUIDANCE.
|
|
claimed.skills = list(dict.fromkeys([*(claimed.skills or []), "sdlc-review"]))
|
|
try:
|
|
pid = _call_spawn_fn(spawn_fn if spawn_fn is not None else _default_spawn, claimed, str(workspace), board)
|
|
if pid:
|
|
_set_worker_pid(conn, claimed.id, int(pid))
|
|
# Fires AFTER the PID (when reported) is durably persisted. Best-effort.
|
|
_kb._fire_worker_spawned_hook(conn, claimed, str(workspace), pid, board=board)
|
|
# consecutive_failures is deliberately NOT reset here: resetting on
|
|
# spawn would let a task that keeps timing out loop forever. Cleared
|
|
# only on successful completion (complete_task).
|
|
result.spawned.append((claimed.id, claimed.assignee or "", str(workspace)))
|
|
_count_spawn(claimed.assignee)
|
|
return True
|
|
except Exception as exc:
|
|
from tools.process_registry import RestartSafeScopeUnavailable
|
|
|
|
# The host refused the spawn (no restart-safe scope): nothing about the
|
|
# card ran, so it must not spend the card's retry budget (#114720).
|
|
infrastructure = isinstance(exc, RestartSafeScopeUnavailable)
|
|
if infrastructure:
|
|
_kb._log.warning("kanban dispatcher: spawn of %s deferred, host cannot place the worker: %s", claimed.id, exc)
|
|
if _record_task_failure(
|
|
conn, claimed.id, str(exc),
|
|
outcome="spawn_failed", failure_limit=failure_limit, release_claim=True, end_run=True,
|
|
infrastructure=infrastructure,
|
|
):
|
|
result.auto_blocked.append(claimed.id)
|
|
return False
|
|
|
|
|
|
def _apply_default_assignee(
|
|
conn: sqlite3.Connection, task_id: str, assignee: str, *, dry_run: bool,
|
|
) -> bool:
|
|
"""Persist ``kanban.default_assignee`` on an unassigned ready row.
|
|
|
|
Mutating the row keeps board state honest: the task is legitimately owned
|
|
by the default, not "unassigned but secretly routed". ``dry_run`` reports
|
|
without writing. Returns False when the write failed.
|
|
"""
|
|
if dry_run:
|
|
return True
|
|
try:
|
|
with _kb.write_txn(conn):
|
|
conn.execute(
|
|
"UPDATE tasks SET assignee = ? WHERE id = ? "
|
|
"AND (assignee IS NULL OR assignee = '')",
|
|
(assignee, task_id),
|
|
)
|
|
_kb._append_event(
|
|
conn, task_id, "assigned",
|
|
{"assignee": assignee, "source": "kanban.default_assignee"},
|
|
)
|
|
except Exception:
|
|
_kb._log.debug(
|
|
"kanban dispatch: failed to apply default_assignee=%r to task %s",
|
|
assignee, task_id, exc_info=True,
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
def _run_reclaim_phase(
|
|
conn: sqlite3.Connection,
|
|
result: DispatchResult,
|
|
*,
|
|
stale_timeout_seconds: int,
|
|
failure_limit: int,
|
|
reconcile_orphans: bool,
|
|
board: Optional[str] = None,
|
|
) -> None:
|
|
"""Reclaim stale/orphaned/crashed/timed-out running tasks, then promote."""
|
|
reap_worker_zombies()
|
|
result.reaped_terminal_workers = reap_terminal_workers(conn)
|
|
result.reclaimed = _kb.release_stale_claims(conn, failure_limit=failure_limit)
|
|
if reconcile_orphans:
|
|
result.reconciled_orphans = reconcile_orphaned_running(conn)
|
|
result.stale = detect_stale_running(conn, stale_timeout_seconds=stale_timeout_seconds)
|
|
result.crashed = detect_crashed_workers(conn, board=board)
|
|
# Side-channel attributes (see detect_crashed_workers); rate-limited tasks
|
|
# went back to ``ready`` and the respawn guard defers them until quota clears.
|
|
result.auto_blocked.extend(getattr(detect_crashed_workers, "_last_auto_blocked", []))
|
|
result.rate_limited.extend(getattr(detect_crashed_workers, "_last_rate_limited", []))
|
|
result.timed_out = enforce_max_runtime(conn)
|
|
result.promoted = _kb.recompute_ready(conn, failure_limit=failure_limit)
|
|
|
|
|
|
def _tick_spawn_budget(
|
|
conn: sqlite3.Connection,
|
|
result: DispatchResult,
|
|
*,
|
|
max_spawn: Optional[int],
|
|
max_in_progress: Optional[int],
|
|
board: Optional[str],
|
|
) -> tuple[bool, Optional[int]]:
|
|
"""``(may_spawn, spawn_budget)`` for this tick; ``budget None`` = uncapped.
|
|
|
|
``max_spawn`` is a live per-board concurrency cap (running + this tick's
|
|
spawns), not a per-tick budget — a per-tick reading would grow concurrency
|
|
by N every tick. ``max_in_progress`` is a HOST-level cap: running workers on
|
|
every other board count against the same budget, else N boards multiply the
|
|
cap by N — exactly the fan-out the memory-derived default exists to prevent.
|
|
"""
|
|
# Count already-running tasks so max_spawn enforces concurrency, not a
|
|
# per-tick budget: "running" tasks stay running until the worker makes a terminal
|
|
# board call (kanban_complete/kanban_block/kanban_request_review) or the TTL reclaims them.
|
|
running_count = 0
|
|
spawn_budget: Optional[int] = None
|
|
if max_spawn is not None or max_in_progress is not None:
|
|
running_count = count_running_tasks(conn)
|
|
|
|
# Both ready and review loops consume from the same budget.
|
|
if max_spawn is not None:
|
|
if running_count >= max_spawn:
|
|
return False, None
|
|
spawn_budget = max_spawn - running_count
|
|
|
|
if max_in_progress is not None:
|
|
total_running = running_count + count_running_tasks_other_boards(board)
|
|
if total_running >= max_in_progress:
|
|
return False, None
|
|
remaining = max_in_progress - total_running
|
|
if spawn_budget is None or spawn_budget > remaining:
|
|
spawn_budget = remaining
|
|
|
|
# Memory-pressure guard: a static cap can't see the host's actual state.
|
|
# critical -> spawn nothing this tick; elevated -> at most one new worker.
|
|
# Reclaim/promotion already ran, so bookkeeping stays live; deferred tasks
|
|
# wait for a later tick. "unknown" imposes no restriction.
|
|
pressure = _memory_pressure_level()
|
|
if pressure == "critical":
|
|
result.memory_pressure = pressure
|
|
_kb._log.warning(
|
|
"kanban dispatch: system memory pressure is critical; "
|
|
"spawning no new workers this tick (deferred, not dropped)"
|
|
)
|
|
return False, None
|
|
if pressure == "elevated":
|
|
result.memory_pressure = pressure
|
|
if spawn_budget is None or spawn_budget > 1:
|
|
_kb._log.warning(
|
|
"kanban dispatch: system memory pressure is elevated; "
|
|
"limiting to at most 1 new worker this tick"
|
|
)
|
|
spawn_budget = 1
|
|
return True, spawn_budget
|
|
|
|
|
|
def _lane_rows(conn: sqlite3.Connection, status: str) -> list[sqlite3.Row]:
|
|
"""Unclaimed rows of one lane in dispatch order."""
|
|
return conn.execute(
|
|
"SELECT id, assignee FROM tasks "
|
|
f"WHERE status = '{status}' AND claim_lock IS NULL "
|
|
"ORDER BY priority DESC, created_at ASC"
|
|
).fetchall()
|
|
|
|
|
|
def _any_spawnable_review(
|
|
conn: sqlite3.Connection,
|
|
review_rows: list[sqlite3.Row],
|
|
*,
|
|
per_profile_cap: Optional[int] = None,
|
|
per_profile_running: Optional[dict[str, int]] = None,
|
|
) -> bool:
|
|
"""Mirror review dispatch gates before reserving ready-lane capacity.
|
|
|
|
Unavailable profile metadata retains the historic fail-open behavior. A
|
|
review row that :func:`_dispatch_lane_task` would refuse this tick — its
|
|
assignee already at the per-profile cap, or respawn-guarded — cannot
|
|
consume the reservation, so it must not withhold capacity from an
|
|
otherwise ready task (one such row would pin ``ready_budget`` to 0).
|
|
"""
|
|
if not review_rows:
|
|
return False
|
|
profile_exists = _profile_exists_fn()
|
|
running = per_profile_running or {}
|
|
for row in review_rows:
|
|
assignee = row["assignee"]
|
|
if not assignee:
|
|
continue
|
|
if profile_exists is not None and not profile_exists(assignee):
|
|
continue
|
|
if per_profile_cap is not None and running.get(assignee, 0) >= per_profile_cap:
|
|
continue
|
|
if check_respawn_guard(conn, row["id"], lane="review") is None:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _resolve_default_assignee(default_assignee: Optional[str]) -> Optional[str]:
|
|
"""``kanban.default_assignee`` when it names a real profile this home may
|
|
claim (``kanban.dispatch_profiles`` gated, same predicate as the spawn
|
|
gate). Otherwise ``None`` so an unassigned shared-board card is never
|
|
written to. When the profiles module isn't importable trust the
|
|
operator's config: the downstream check still buckets a missing profile
|
|
as nonspawnable."""
|
|
name = (default_assignee or "").strip() or None
|
|
if name:
|
|
profile_exists = _profile_exists_fn()
|
|
if profile_exists is not None and not profile_exists(name):
|
|
return None
|
|
return name
|
|
|
|
|
|
# The dispatch lock has been released here. Fire the tick observer strictly OUTSIDE the single-writer
|
|
# critical section (#56066 sweeper finding / #64231 disposition): a slow subscriber must never extend the
|
|
# lock hold and stall a sibling dispatcher's tick.
|
|
def _dispatch_once_locked(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
spawn_fn=None,
|
|
ttl_seconds: Optional[int] = None,
|
|
dry_run: bool = False,
|
|
max_spawn: Optional[int] = None,
|
|
max_in_progress: Optional[int] = None,
|
|
failure_limit: int = DEFAULT_FAILURE_LIMIT,
|
|
stale_timeout_seconds: int = 0,
|
|
board: Optional[str] = None,
|
|
default_assignee: Optional[str] = None,
|
|
max_in_progress_per_profile: Optional[int] = None,
|
|
reconcile_orphans: bool = True,
|
|
) -> DispatchResult:
|
|
"""One dispatcher tick: reclaim stale/crashed running tasks, promote
|
|
todo -> ready, then atomically claim each spawnable ready/review row and
|
|
call ``spawn_fn(task, workspace_path, board) -> Optional[int]``, recording
|
|
the PID so later ticks catch crashes before the TTL. Cap semantics:
|
|
:func:`_tick_spawn_budget`."""
|
|
result = DispatchResult()
|
|
_run_reclaim_phase(
|
|
conn, result, stale_timeout_seconds=stale_timeout_seconds,
|
|
failure_limit=failure_limit, reconcile_orphans=reconcile_orphans, board=board,
|
|
)
|
|
may_spawn, spawn_budget = _tick_spawn_budget(
|
|
conn, result, max_spawn=max_spawn, max_in_progress=max_in_progress, board=board,
|
|
)
|
|
if not may_spawn:
|
|
return result
|
|
|
|
ready_rows = _lane_rows(conn, "ready")
|
|
# Review rows are enumerated up front so the budget split can see whether
|
|
# review work exists at all.
|
|
review_rows = _lane_rows(conn, "review") if review_dispatch_enabled() else []
|
|
# Per-profile cap. Deferred tasks go to skipped_per_profile_capped, not
|
|
# skipped_unassigned — "busy, retry later" differs from "needs routing".
|
|
# Resolved BEFORE the review reservation so the reservation can see which
|
|
# review rows the lane loop would refuse this tick.
|
|
per_profile_cap = max_in_progress_per_profile if (
|
|
# Per-profile concurrency cap (#21582): when set, track how many workers each assignee already has
|
|
# in flight, and refuse to spawn when this would push that assignee past the cap. Prevents fan-out
|
|
# workloads from melting a single profile's local model / API quota / browser pool while leaving
|
|
# other profiles idle.
|
|
isinstance(max_in_progress_per_profile, int)
|
|
and max_in_progress_per_profile > 0
|
|
) else None
|
|
per_profile_running: dict[str, int] = {}
|
|
if per_profile_cap is not None:
|
|
for prow in conn.execute(
|
|
"SELECT assignee, COUNT(*) AS n FROM tasks "
|
|
"WHERE status = 'running' AND assignee IS NOT NULL "
|
|
"GROUP BY assignee"
|
|
):
|
|
per_profile_running[prow["assignee"]] = int(prow["n"])
|
|
# Review-lane reservation: the ready loop runs first and would otherwise
|
|
# consume the ENTIRE shared budget, starving reviews under a sustained ready
|
|
# backlog. When spawnable review work exists and there is any budget, hold
|
|
# one slot back.
|
|
ready_budget = spawn_budget
|
|
if spawn_budget is not None and spawn_budget > 0 and _any_spawnable_review(
|
|
conn, review_rows,
|
|
per_profile_cap=per_profile_cap, per_profile_running=per_profile_running,
|
|
):
|
|
ready_budget = max(spawn_budget - 1, 0)
|
|
lane_kwargs: dict[str, Any] = dict(
|
|
dry_run=dry_run, ttl_seconds=ttl_seconds, board=board,
|
|
failure_limit=failure_limit, spawn_fn=spawn_fn,
|
|
per_profile_cap=per_profile_cap, per_profile_running=per_profile_running,
|
|
)
|
|
default_assignee = _resolve_default_assignee(default_assignee)
|
|
spawned = 0
|
|
for row in ready_rows:
|
|
if ready_budget is not None and spawned >= ready_budget:
|
|
break
|
|
row_assignee = row["assignee"]
|
|
if not row_assignee:
|
|
# Honour kanban.default_assignee so an unassigned task doesn't
|
|
# park in 'ready' forever.
|
|
if not default_assignee or not _apply_default_assignee(
|
|
conn, row["id"], default_assignee, dry_run=dry_run,
|
|
):
|
|
result.skipped_unassigned.append(row["id"])
|
|
continue
|
|
row_assignee = default_assignee
|
|
result.auto_assigned_default.append(row["id"])
|
|
if _dispatch_lane_task(conn, row, row_assignee, result, lane="ready", **lane_kwargs):
|
|
spawned += 1
|
|
|
|
# A review agent (sdlc-review) approves (→ done) or requests changes
|
|
# (→ ready/todo). Review spawns share max_spawn with ready tasks. The loop
|
|
# checks the FULL shared ``spawn_budget`` — the reservation above caps the
|
|
# ready lane, it grants no extra capacity here.
|
|
for row in review_rows:
|
|
if spawn_budget is not None and spawned >= spawn_budget:
|
|
break
|
|
if not row["assignee"]:
|
|
result.skipped_unassigned.append(row["id"])
|
|
continue
|
|
if _dispatch_lane_task(conn, row, row["assignee"], result, lane="review", **lane_kwargs):
|
|
spawned += 1
|
|
return result
|
|
|
|
|
|
def _positive_int(value: Any, default: int, *, minimum: int = 1) -> int:
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return parsed if parsed >= minimum else default
|
|
|
|
|
|
def worker_log_rotation_config(kanban_cfg: Optional[dict] = None) -> tuple[int, int]:
|
|
"""Return ``(rotate_bytes, backup_count)`` for worker log rotation.
|
|
Defaults: rotate at 2 MiB, keep one backup (``.log.1``); both overridable
|
|
from ``config.yaml``.
|
|
"""
|
|
if kanban_cfg is None:
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
|
|
kanban_cfg = (load_config().get("kanban") or {})
|
|
except Exception:
|
|
kanban_cfg = {}
|
|
kanban_cfg = kanban_cfg or {}
|
|
max_bytes = _positive_int(kanban_cfg.get("worker_log_rotate_bytes"), DEFAULT_LOG_ROTATE_BYTES, minimum=1)
|
|
backup_count = _positive_int(kanban_cfg.get("worker_log_backup_count"), DEFAULT_LOG_BACKUP_COUNT, minimum=0)
|
|
return max_bytes, backup_count
|
|
|
|
|
|
def _rotated_log_path(log_path: Path, generation: int) -> Path:
|
|
return log_path.with_suffix(log_path.suffix + f".{generation}")
|
|
|
|
|
|
def _rotate_worker_log(
|
|
log_path: Path,
|
|
max_bytes: int,
|
|
backup_count: int = DEFAULT_LOG_BACKUP_COUNT,
|
|
) -> None:
|
|
"""Rotate ``<log>`` when it exceeds ``max_bytes``: ``<log>`` → ``<log>.1``,
|
|
older generations shift up to ``backup_count``.
|
|
"""
|
|
try:
|
|
if not log_path.exists() or log_path.stat().st_size <= max_bytes:
|
|
return
|
|
backup_count = _positive_int(backup_count, DEFAULT_LOG_BACKUP_COUNT, minimum=0)
|
|
if backup_count == 0:
|
|
log_path.unlink()
|
|
return
|
|
oldest = _rotated_log_path(log_path, backup_count)
|
|
with contextlib.suppress(OSError):
|
|
if oldest.exists():
|
|
oldest.unlink()
|
|
for generation in range(backup_count - 1, 0, -1):
|
|
src = _rotated_log_path(log_path, generation)
|
|
if not src.exists():
|
|
continue
|
|
with contextlib.suppress(OSError):
|
|
src.rename(_rotated_log_path(log_path, generation + 1))
|
|
log_path.rename(_rotated_log_path(log_path, 1))
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _module_hermes_argv() -> list[str]:
|
|
"""Interpreter-bound Hermes CLI invocation (``hermes_cli.main`` is the
|
|
console-script target — there is no top-level ``hermes`` package)."""
|
|
return [sys.executable, "-m", "hermes_cli.main"]
|
|
|
|
|
|
def _propagate_module_import_root(cmd: list[str], env: dict[str, str]) -> None:
|
|
"""Put the running install's package root on a module-form worker's path.
|
|
|
|
``_resolve_hermes_argv`` proves ``hermes_cli`` importable in THIS process,
|
|
where a store-python shim has the repo root on ``sys.path`` in-process;
|
|
the spawned child runs the bare ``sys.executable`` from the task workspace
|
|
with a scrubbed ``PYTHONPATH`` and cannot import the package the parent
|
|
just proved importable — it dies before any work and the board
|
|
auto-blocks (#122299, #122487, #122500). Same-interpreter child, so the
|
|
root is version-safe to propagate; ``hermes_cli.main``'s own bootstrap
|
|
then owns dependency activation as usual. A resolved shim path owns its
|
|
imports and is left alone. Same pin cron's external worker uses (#112729).
|
|
"""
|
|
if cmd[1:3] != ["-m", "hermes_cli.main"]:
|
|
return
|
|
from cron.scheduler_worker_env import pin_hermes_tree_on_pythonpath
|
|
|
|
pin_hermes_tree_on_pythonpath(env, Path(__file__).resolve().parents[1])
|
|
|
|
|
|
def _absolute_hermes_path(path: str) -> str:
|
|
"""Return an absolute filesystem path for a resolved Hermes shim."""
|
|
expanded = os.path.expanduser(path)
|
|
return expanded if os.path.isabs(expanded) else os.path.abspath(expanded)
|
|
|
|
|
|
def _looks_like_path(value: str) -> bool:
|
|
"""Return true when a command override is an explicit path, not a name."""
|
|
expanded = os.path.expanduser(value)
|
|
return (
|
|
expanded.startswith("~")
|
|
or os.path.isabs(expanded)
|
|
or bool(os.path.dirname(expanded))
|
|
or "\\" in expanded
|
|
or bool(re.match(r"^[A-Za-z]:", expanded))
|
|
)
|
|
|
|
|
|
def _is_windows_batch_shim(path: str) -> bool:
|
|
"""Return true for Windows shell/batch shims that should not be argv[0]."""
|
|
return path.lower().endswith((".cmd", ".bat"))
|
|
|
|
|
|
def _path_search_names(command: str) -> list[str]:
|
|
"""Return executable names to try for an unqualified command."""
|
|
if not _kb._IS_WINDOWS or os.path.splitext(command)[1]:
|
|
return [command]
|
|
raw = os.environ.get("PATHEXT") or ".COM;.EXE;.BAT;.CMD"
|
|
return [command + ext for ext in raw.split(";") if ext]
|
|
|
|
|
|
def _safe_which_no_cwd(command: str) -> Optional[str]:
|
|
"""Resolve a bare command from PATH without implicit current-dir search.
|
|
|
|
On Windows ``shutil.which`` may search the current directory before PATH
|
|
for bare names — unsafe for a dispatcher. Only explicit PATH entries are
|
|
considered; empty / ``.`` entries are skipped.
|
|
"""
|
|
for raw_dir in os.environ.get("PATH", "").split(os.pathsep):
|
|
if not raw_dir or raw_dir == ".":
|
|
continue
|
|
directory = os.path.expanduser(raw_dir)
|
|
for name in _path_search_names(command):
|
|
candidate = os.path.join(directory, name)
|
|
if os.path.isfile(candidate) and (_kb._IS_WINDOWS or os.access(candidate, os.X_OK)):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _hermes_path_argv(path: str) -> list[str]:
|
|
"""argv for a resolved Hermes executable path. Windows batch shims
|
|
(``.cmd``/``.bat``) are unsafe as argv[0] because the argument vector
|
|
includes task-derived values; prefer the module form."""
|
|
if _kb._IS_WINDOWS and _is_windows_batch_shim(path):
|
|
return _module_hermes_argv()
|
|
return [_absolute_hermes_path(path)]
|
|
|
|
|
|
def _resolve_hermes_argv() -> list[str]:
|
|
"""Resolve the ``hermes`` invocation as argv for ``Popen``: ``$HERMES_BIN``
|
|
(path-like -> absolute; bare names keep PATH semantics, never a
|
|
same-directory file), then the running interpreter's ``sys.executable -m
|
|
hermes_cli.main`` (exactly this install; also covers shim-less cron,
|
|
systemd ``User=``, launchd), then ``which("hermes")`` (Windows: safe PATH
|
|
search, batch shims fall back to the module form) only when ``hermes_cli``
|
|
is not importable. The module argv must win over PATH: a PATH-first lookup
|
|
lets an attacker-planted ``hermes`` shadow the running install (#111569).
|
|
Mirrors ``gateway.run._resolve_hermes_bin``; local because ``hermes_cli``
|
|
sits below ``gateway`` in the dependency order.
|
|
"""
|
|
import importlib.util
|
|
import shutil
|
|
|
|
env_bin = os.environ.get("HERMES_BIN", "").strip()
|
|
if env_bin:
|
|
if _looks_like_path(env_bin):
|
|
return _hermes_path_argv(env_bin)
|
|
resolved_env_bin = _safe_which_no_cwd(env_bin)
|
|
if resolved_env_bin:
|
|
return _hermes_path_argv(resolved_env_bin)
|
|
return _module_hermes_argv()
|
|
|
|
try:
|
|
if importlib.util.find_spec("hermes_cli") is not None:
|
|
return _module_hermes_argv()
|
|
except Exception:
|
|
pass
|
|
|
|
hermes_bin = _safe_which_no_cwd("hermes") if _kb._IS_WINDOWS else shutil.which("hermes")
|
|
if hermes_bin:
|
|
return _hermes_path_argv(hermes_bin)
|
|
return _module_hermes_argv()
|
|
|
|
|
|
def _worker_terminal_timeout_env(
|
|
max_runtime_seconds: Optional[int],
|
|
current_timeout: Optional[str],
|
|
) -> Optional[str]:
|
|
"""Return a worker-scoped TERMINAL_TIMEOUT override, if needed.
|
|
|
|
When ``max_runtime_seconds`` exceeds the terminal tool's default timeout,
|
|
raise only the child's default so a long command isn't killed by the
|
|
generic terminal default first.
|
|
"""
|
|
if max_runtime_seconds is None:
|
|
return None
|
|
try:
|
|
runtime = int(max_runtime_seconds)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if runtime <= 0:
|
|
return None
|
|
|
|
desired = max(1, runtime - KANBAN_TERMINAL_TIMEOUT_GRACE_SECONDS)
|
|
try:
|
|
existing = int(str(current_timeout).strip()) if current_timeout else 0
|
|
except (TypeError, ValueError):
|
|
existing = 0
|
|
if existing >= desired:
|
|
return None
|
|
return str(desired)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _worker_profile_scope(hermes_home: str, *, bind_home: bool = True):
|
|
"""Bind an assigned profile's runtime scope (secrets + terminal policy, optionally home) for
|
|
one dispatch-side read or spawn-env build.
|
|
|
|
The dispatcher runs detached from any turn, so nothing binds a profile for it: ``load_config``,
|
|
the toolset probes' ``get_secret`` reads and ``build_subprocess_env``'s passthrough resolution
|
|
all fall back to the LAUNCH profile's ambient ``os.environ`` / ``TERMINAL_*``. Binding was
|
|
previously conditional on ``is_multiplex_active()``, so on a single-profile host a worker for
|
|
profile B was built entirely from the dispatcher's own environment.
|
|
|
|
``bind_home=False`` for the spawn-env build: which variables may cross into a child is the
|
|
DISPATCHER's ``terminal.env_passthrough`` policy (#109494, read through the home override) —
|
|
only their VALUES come from the assignee's scope, so that branch binds the secret scope alone.
|
|
Toolset resolution binds the home and the terminal policy, as it always has.
|
|
|
|
The secret mapping is never widened: a profile that is not this process's own home gets its own
|
|
``.env`` + external sources ONLY, while the launch home keeps its established
|
|
env-over-``.env`` precedence (``launch_secret_scope``) so systemd / ``op run`` injection still
|
|
resolves for a standalone dispatcher.
|
|
"""
|
|
from agent.secret_scope import build_profile_secret_scope, reset_secret_scope, set_secret_scope
|
|
from hermes_constants import get_process_hermes_home, reset_hermes_home_override, set_hermes_home_override
|
|
from tools.terminal_scope import install_profile_terminal_scope, reset_terminal_scope
|
|
from tui_gateway.launch_profile_policy import launch_secret_scope, launch_terminal_env
|
|
|
|
home = Path(hermes_home)
|
|
is_launch_home = str(home.resolve()) == str(Path(get_process_hermes_home()).resolve())
|
|
home_token = secret_token = terminal_token = None
|
|
try:
|
|
home_token = set_hermes_home_override(str(home)) if bind_home else None
|
|
secret_token = set_secret_scope(
|
|
launch_secret_scope(home) if is_launch_home else build_profile_secret_scope(home),
|
|
profile_home=None if is_launch_home else str(home))
|
|
terminal_token = install_profile_terminal_scope(
|
|
home, env_overlay=launch_terminal_env() if is_launch_home else None) if bind_home else None
|
|
yield
|
|
finally:
|
|
if terminal_token is not None:
|
|
reset_terminal_scope(terminal_token)
|
|
if secret_token is not None:
|
|
reset_secret_scope(secret_token)
|
|
if home_token is not None:
|
|
reset_hermes_home_override(home_token)
|
|
|
|
|
|
def _resolve_worker_cli_toolsets(hermes_home: Optional[str]) -> Optional[list[str]]:
|
|
"""Return the assigned profile's effective CLI toolsets for a worker.
|
|
|
|
Resolved at dispatch time and passed as an explicit ``--toolsets`` pin so
|
|
worker startup cannot fall back to a stale root/active-profile config or a
|
|
profile whose top-level ``toolsets`` is only the kanban orchestrator
|
|
surface. ``model_tools`` still appends the task-scoped kanban lifecycle
|
|
tools when ``HERMES_KANBAN_TASK`` is set.
|
|
"""
|
|
if not hermes_home:
|
|
return None
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
from hermes_cli.tools_config import _get_platform_tools
|
|
|
|
with _worker_profile_scope(hermes_home):
|
|
cfg = load_config()
|
|
toolsets = sorted(_get_platform_tools(cfg, "cli"))
|
|
return toolsets or None
|
|
except Exception as exc:
|
|
_kb._log.debug(
|
|
"kanban worker: could not resolve CLI toolsets for HERMES_HOME=%r (%s)",
|
|
hermes_home,
|
|
exc,
|
|
)
|
|
return None
|
|
|
|
|
|
_retagged_workspace_roots: set[str] = set()
|
|
|
|
|
|
def _retag_legacy_worker_sessions(workspaces_root_path: str) -> None:
|
|
"""Reclaim pre-tag worker rows in state.db so they leave the session lists.
|
|
|
|
Best-effort: the durable gate is ``state_meta`` in
|
|
``retag_kanban_worker_sessions``; the in-process set avoids reopening
|
|
state.db on every spawn. A tick must never fail because a session DB was
|
|
busy or missing.
|
|
"""
|
|
if workspaces_root_path in _retagged_workspace_roots:
|
|
return
|
|
try:
|
|
from hermes_state_registry import acquire, release_or_close
|
|
|
|
# Inside the gateway the dispatcher shares the process's registry handle; a bare
|
|
# SessionDB() here was one more writer connection on the same state.db (#100896).
|
|
db = acquire()
|
|
try:
|
|
db.retag_kanban_worker_sessions(workspaces_root_path)
|
|
finally:
|
|
release_or_close(db)
|
|
_retagged_workspace_roots.add(workspaces_root_path)
|
|
except Exception as exc:
|
|
_kb._log.debug("kanban worker: legacy session retag skipped (%s)", exc)
|
|
|
|
|
|
def _worker_argv(task: Task, profile_arg: str, hermes_home: Optional[str]) -> list[str]:
|
|
"""Build the ``hermes -p <profile> --cli ... chat -q ...`` worker command."""
|
|
cmd = [
|
|
*_resolve_hermes_argv(),
|
|
"-p", profile_arg,
|
|
# A worker must NEVER boot the interactive TUI: its no-TTY bail-out
|
|
# exits 0 without doing the task → "protocol violation" every attempt.
|
|
"--cli",
|
|
# Workers run under a profile-scoped HERMES_HOME and so see that
|
|
# profile's shell-hook allowlist; pass --accept-hooks explicitly so
|
|
# configured hooks still register.
|
|
"--accept-hooks",
|
|
]
|
|
# One `--skills X` pair per name: easier to read in `ps` and avoids quoting
|
|
# ambiguity if a skill name contains unusual chars.
|
|
for sk in task.skills or ():
|
|
if sk:
|
|
cmd.extend(["--skills", sk])
|
|
if task.model_override:
|
|
cmd.extend(["-m", task.model_override])
|
|
# Pin the provider too so the worker resolves the model against the
|
|
# intended backend (model X with provider Y is the classic board-stall).
|
|
if task.provider_override:
|
|
cmd.extend(["--provider", task.provider_override])
|
|
# Independent of the model override — a task can run the profile's own
|
|
# model at a different depth.
|
|
if task.reasoning_effort:
|
|
cmd.extend(["--reasoning", task.reasoning_effort])
|
|
worker_toolsets = _resolve_worker_cli_toolsets(hermes_home)
|
|
if worker_toolsets:
|
|
cmd.extend(["--toolsets", ",".join(worker_toolsets)])
|
|
cmd.extend(["chat", "-q", f"work kanban task {task.id}"])
|
|
# goal_mode rides the same `-q` path: cli.py runs the judge loop there too, so the
|
|
# worker log keeps its live tool feed (forcing -Q blanked it).
|
|
return cmd
|
|
|
|
|
|
def _open_worker_log(task: Task, board: Optional[str]):
|
|
"""Append-mode per-task log (a re-run on unblock appends, never overwrites),
|
|
rotated first. Anchored at the board root (not the shared kanban root) so
|
|
`hermes kanban log` reads its own file and boards sharing task ids don't
|
|
collide."""
|
|
log_dir = _kb.worker_logs_dir(board=board)
|
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
log_path = log_dir / f"{task.id}.log"
|
|
rotate_bytes, backup_count = worker_log_rotation_config()
|
|
_rotate_worker_log(log_path, rotate_bytes, backup_count)
|
|
return open(log_path, "ab")
|
|
|
|
|
|
def _restart_safe_worker_argv(task: Task, command: list[str]) -> list[str]:
|
|
"""Wrap a systemd-hosted dispatcher's worker in the shared restart-safe scope.
|
|
|
|
Kanban workers are long-lived agentic runs that outlive the dispatcher
|
|
tick, so they never take cron's degraded mode under the managed gateway:
|
|
``require_restart_safe_scope=True`` makes the helper raise
|
|
``RestartSafeScopeUnavailable`` there (an infrastructure spawn failure the
|
|
dispatcher does not charge to the card). Under any other systemd unit
|
|
(``Type=oneshot`` dispatch timers, #113612) ``outlives_parent=True`` gets the
|
|
worker its own scope so the unit's cgroup teardown cannot kill it.
|
|
"""
|
|
from tools.process_registry import restart_safe_gateway_child_argv
|
|
|
|
if task.current_run_id is None:
|
|
# Outside managed systemd this is harmless, but a managed dispatch must
|
|
# never mint an untraceable worker. Check topology through the shared
|
|
# helper first, using a placeholder suffix that cannot be launched.
|
|
dispatch = restart_safe_gateway_child_argv(
|
|
command,
|
|
unit_suffix=f"kanban-{task.id}-run-missing",
|
|
require_restart_safe_scope=True,
|
|
outlives_parent=True,
|
|
)
|
|
if dispatch.mode != "in_process":
|
|
raise RuntimeError(
|
|
"cannot create restart-safe systemd scope for Kanban worker: "
|
|
"the claimed task has no current run id"
|
|
)
|
|
return command
|
|
|
|
return restart_safe_gateway_child_argv(
|
|
command,
|
|
unit_suffix=f"kanban-{task.id}-run-{task.current_run_id}",
|
|
require_restart_safe_scope=True,
|
|
outlives_parent=True,
|
|
).argv
|
|
|
|
|
|
def _default_spawn(task: Task, workspace: str, *, board: Optional[str] = None) -> Optional[int]:
|
|
"""Fire-and-forget ``hermes -p <profile> chat -q ...`` subprocess.
|
|
|
|
Returns the child's PID so the dispatcher can detect crashes before the
|
|
claim TTL expires; completion is still observed via the worker's own
|
|
``complete`` / ``block`` transitions. ``board`` pins the child's
|
|
``HERMES_KANBAN_DB`` / ``HERMES_KANBAN_BOARD`` / workspaces_root to the
|
|
board the task was claimed from, so workers cannot see other boards.
|
|
"""
|
|
if not task.assignee:
|
|
raise ValueError(f"task {task.id} has no assignee")
|
|
|
|
from hermes_cli.profiles import normalize_profile_name, resolve_profile_env
|
|
|
|
profile_arg = normalize_profile_name(task.assignee)
|
|
|
|
from agent.secret_scope import is_multiplex_active
|
|
from tools.environments.local import _is_routed_home, build_subprocess_env, strip_launch_profile_env
|
|
|
|
try:
|
|
profile_home = resolve_profile_env(profile_arg)
|
|
except FileNotFoundError:
|
|
# No profile dir (isolated test fixtures) — the CLI resolves it from
|
|
# HERMES_PROFILE (set below) instead.
|
|
profile_home = None
|
|
|
|
# Scrub for a ROUTED home, not only under multiplex: the authority test is "does this worker act
|
|
# for another profile", exactly as served_profile_child_env decides it (tools/environments/local.py).
|
|
# Gating on the gateway-wide flag left B's worker inheriting the dispatcher's own OPENAI_API_KEY and
|
|
# systemd-injected tokens on every single-profile host.
|
|
routed = bool(profile_home) and _is_routed_home(profile_home)
|
|
# build_subprocess_env's secret scrub resolves terminal.env_passthrough vars through get_secret(),
|
|
# which without a bound scope reads the LAUNCH profile's ambient environment for a worker spawned
|
|
# on B's behalf (and raises under multiplex) — so bind B's secret scope around the build.
|
|
with (_worker_profile_scope(profile_home, bind_home=False) if profile_home
|
|
else contextlib.nullcontext()):
|
|
env = build_subprocess_env(
|
|
scrub_secrets=is_multiplex_active() or routed,
|
|
inherit_profile_home=True,
|
|
)
|
|
# The dispatcher is detached from every conversation; its worker must never
|
|
# inherit routing mirrored by a previous gateway turn.
|
|
from gateway.session_context import _VAR_MAP
|
|
for key in _VAR_MAP:
|
|
env.pop(key, None)
|
|
|
|
# Inject HERMES_HOME so the worker reads the profile-scoped config.yaml:
|
|
# without it the child's get_hermes_home() falls back to the DEFAULT
|
|
# profile root because `hermes -p` applies its override before
|
|
# hermes_constants is imported.
|
|
if profile_home:
|
|
env["HERMES_HOME"] = profile_home
|
|
# A multiplexer dispatching for another profile must not hand it the launch
|
|
# profile's .env settings / TERMINAL_* policy — a standalone dispatcher never would.
|
|
strip_launch_profile_env(env, profile_home)
|
|
if task.tenant:
|
|
env["HERMES_TENANT"] = task.tenant
|
|
env["HERMES_KANBAN_TASK"] = task.id
|
|
env["HERMES_KANBAN_WORKSPACE"] = workspace
|
|
# Tag the session `kanban` so session-browsing surfaces filter it out by
|
|
# source instead of rendering one sidebar row per attempt.
|
|
env["HERMES_SESSION_SOURCE"] = "kanban"
|
|
# TERMINAL_CWD takes precedence over process cwd in file_tools and
|
|
# build_context_files_prompt; without it relative writes land in the gateway
|
|
# user's home and workers load the gateway's AGENTS.md. file_tools rejects
|
|
# relative / sentinel values, so only set a real absolute directory.
|
|
# Pin TERMINAL_CWD to the task's workspace so the worker's file tools and context-file loader anchor on
|
|
# the workspace, not whatever cwd the dispatching gateway happened to export. The worker subprocess is
|
|
# already launched with cwd=workspace, but TERMINAL_CWD takes precedence over the process cwd in both
|
|
# file_tools._resolve_base_dir (#41312 — relative write_file paths were landing in the gateway user's
|
|
# home) and build_context_files_prompt (#34619 — workers loaded the dispatching gateway's AGENTS.md
|
|
# instead of the task's). Setting it to the workspace fixes both: the workspace is where the task's work
|
|
# actually happens.
|
|
if workspace and os.path.isabs(workspace) and os.path.isdir(workspace):
|
|
env["TERMINAL_CWD"] = workspace
|
|
if task.branch_name:
|
|
env["HERMES_KANBAN_BRANCH"] = task.branch_name
|
|
if task.current_run_id is not None:
|
|
env["HERMES_KANBAN_RUN_ID"] = str(task.current_run_id)
|
|
if task.claim_lock:
|
|
env["HERMES_KANBAN_CLAIM_LOCK"] = task.claim_lock
|
|
# Goal-loop mode (Ralph-style /goal judge loop in cli.py quiet-mode path).
|
|
# Only set when enabled so non-goal tasks keep a clean env.
|
|
if task.goal_mode:
|
|
env["HERMES_KANBAN_GOAL_MODE"] = "1"
|
|
if task.goal_max_turns is not None:
|
|
env["HERMES_KANBAN_GOAL_MAX_TURNS"] = str(int(task.goal_max_turns))
|
|
for var in ("TERMINAL_TIMEOUT", "TERMINAL_MAX_FOREGROUND_TIMEOUT"):
|
|
override = _worker_terminal_timeout_env(task.max_runtime_seconds, env.get(var))
|
|
if override is not None:
|
|
env[var] = override
|
|
# Pin the board DB + workspaces root so the worker's kanban paths still
|
|
# match after `hermes -p` rewrites HERMES_HOME (symlink / Docker layouts).
|
|
env["HERMES_KANBAN_DB"] = str(_kb.kanban_db_path(board=board))
|
|
env["HERMES_KANBAN_WORKSPACES_ROOT"] = str(_kb.workspaces_root(board=board))
|
|
_retag_legacy_worker_sessions(env["HERMES_KANBAN_WORKSPACES_ROOT"])
|
|
# Board slug — defense-in-depth pin if a path is resolved without the
|
|
# DB / workspaces env vars.
|
|
env["HERMES_KANBAN_BOARD"] = _kb._normalize_board_slug(board) or _kb.get_current_board()
|
|
# kanban_comment reads HERMES_PROFILE for its default author; `-p` alone
|
|
# doesn't set the env var.
|
|
env["HERMES_PROFILE"] = profile_arg
|
|
# This is the grant boundary: the dispatcher assigned this new worker's task.
|
|
from agent.delegation_context import DELEGATED_CHILD_ENV_MARKER
|
|
env.pop(DELEGATED_CHILD_ENV_MARKER, None)
|
|
# `--cli` is the highest-precedence TUI override; dropping HERMES_TUI covers
|
|
# older hermes builds on PATH that predate the flag's precedence.
|
|
env.pop("HERMES_TUI", None)
|
|
|
|
cmd = _worker_argv(task, profile_arg, env.get("HERMES_HOME"))
|
|
# The module argv must carry the import context that made it resolvable:
|
|
# the shim's in-process path injection is invisible to the bare child.
|
|
_propagate_module_import_root(cmd, env)
|
|
# A worker spawned by a managed systemd gateway must leave the gateway's
|
|
# cgroup before startup; otherwise restarting the service kills the worker
|
|
# that is performing the handoff.
|
|
cmd = _restart_safe_worker_argv(task, cmd)
|
|
from tools.process_registry import systemd_user_bus_env
|
|
env = systemd_user_bus_env(env)
|
|
log_f = _open_worker_log(task, board)
|
|
try:
|
|
proc = subprocess.Popen( # noqa: S603 -- argv is a fixed list built above
|
|
cmd,
|
|
cwd=workspace if os.path.isdir(workspace) else None,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=log_f,
|
|
stderr=subprocess.STDOUT,
|
|
env=env,
|
|
start_new_session=True,
|
|
creationflags=subprocess.CREATE_NO_WINDOW if _kb._IS_WINDOWS else 0,
|
|
)
|
|
except FileNotFoundError:
|
|
log_f.close()
|
|
raise RuntimeError(
|
|
"`hermes` executable not found on PATH. "
|
|
"Install Hermes Agent or activate its venv before running the kanban dispatcher."
|
|
)
|
|
# Intentionally NOT closing log_f: the child keeps writing after return;
|
|
# the OS-level FD stays open in the child until it exits.
|
|
if _kb._IS_WINDOWS:
|
|
_live_worker_procs[proc.pid] = proc
|
|
return proc.pid
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Long-lived dispatcher daemon
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def run_daemon(
|
|
*,
|
|
interval: float = 60.0,
|
|
max_spawn: Optional[int] = None,
|
|
failure_limit: int = DEFAULT_FAILURE_LIMIT,
|
|
stop_event=None,
|
|
on_tick=None,
|
|
) -> None:
|
|
"""Run the dispatcher in a loop until interrupted.
|
|
|
|
Calls :func:`dispatch_once` every ``interval`` seconds; exits cleanly on
|
|
SIGINT / SIGTERM so it is systemd-friendly. ``stop_event`` and ``on_tick``
|
|
are test hooks. Each tick resolves ``kanban.max_in_progress`` exactly like
|
|
the gateway dispatcher and ``hermes kanban dispatch`` — the standalone
|
|
daemon must not be the one uncapped entry point.
|
|
"""
|
|
import threading
|
|
|
|
if stop_event is None:
|
|
stop_event = threading.Event()
|
|
|
|
def _handle(_signum, _frame):
|
|
stop_event.set()
|
|
|
|
# Install handlers only on the main thread — tests call this inline from
|
|
# worker threads and signal() would raise there.
|
|
if threading.current_thread() is threading.main_thread():
|
|
for sig_name in ("SIGINT", "SIGTERM"):
|
|
sig = getattr(signal, sig_name, None)
|
|
if sig is not None:
|
|
with contextlib.suppress(ValueError, OSError):
|
|
signal.signal(sig, _handle)
|
|
|
|
while not stop_event.is_set():
|
|
try:
|
|
# Re-resolved every tick (config load is mtime-cached) so operator
|
|
# edits apply without a restart.
|
|
max_in_progress = resolve_max_in_progress(configured_max_in_progress())
|
|
with contextlib.closing(_kbc.connect()) as conn:
|
|
res = dispatch_once(
|
|
conn,
|
|
max_spawn=max_spawn,
|
|
max_in_progress=max_in_progress,
|
|
failure_limit=failure_limit,
|
|
)
|
|
if on_tick is not None:
|
|
with contextlib.suppress(Exception):
|
|
on_tick(res)
|
|
except Exception:
|
|
# Don't let any single tick kill the daemon.
|
|
import traceback
|
|
traceback.print_exc()
|
|
stop_event.wait(timeout=interval)
|
|
|
|
|
|
# Late-bound origin namespace (see module docstring); imported LAST so this
|
|
# module is fully populated before ``kanban_db`` imports from it.
|
|
from hermes_cli import kanban_db as _kb # noqa: E402
|
|
from hermes_cli import kanban_db_connect as _kbc # noqa: E402
|
|
from hermes_cli import kanban_db_workspace as _kbw # noqa: E402
|