Skip to content

feat(kanban): add worker-lane registry for the dispatcher - #2

Open
nikitaBarkov wants to merge 1 commit into
mainfrom
nikita.barkov/workers
Open

feat(kanban): add worker-lane registry for the dispatcher#2
nikitaBarkov wants to merge 1 commit into
mainfrom
nikita.barkov/workers

Conversation

@nikitaBarkov

@nikitaBarkov nikitaBarkov commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Add a worker-lane registry so an out-of-tree integration can register a custom spawn_fn for a non-Hermes assignee — letting the kanban dispatcher launch that runtime directly instead of the default hermes -p <profile> worker.

What does this PR do?

Problem. The dispatcher's worker spawn is hardcoded to hermes -p <assignee> chat (_default_spawn). There was no supported way for an out-of-tree integration (an external CLI runner) to be spawned for a task — the worker-lanes doc explicitly calls this "not yet a paved path." An assignee that isn't a Hermes profile just sits in ready as skipped_nonspawnable.

Solution. A process-local worker-lane registry. A plugin registers a lane at load time via ctx.register_worker_lane(WorkerLane(name, spawn_fn, ...)); the dispatcher then consults the registry when it resolves a ready task's assignee:

  • spawn resolution (_resolve_spawn_fn): an explicit spawn_fn (tests / callers) wins, else a registered lane's spawn_fn, else the default Hermes-profile spawn — so existing profile lanes are unchanged.
  • a registered lane assignee counts as spawnable (_assignee_is_spawnable / has_spawnable_ready / has_spawnable_review), so a lane task is dispatched rather than skipped or stranded.
  • an optional per-lane max_concurrency caps in-flight workers for that lane (falls back to the global per-profile cap).
  • lanes get the same worker contract as Hermes workers via kanban_worker_env (the HERMES_KANBAN_* env vars), so an external runner sees an identical task/workspace/run environment.

The registry is process-local, so registration must happen in the process that runs the dispatcher (typically the gateway that owns kanban dispatch). The lane contract a spawned worker must satisfy (exactly one terminal kanban action, heartbeat, etc.) is documented in website/docs/user-guide/features/kanban-worker-lanes.md.

Related Issue

N/A — no tracking issue in this fork. Context: the worker-lanes doc references upstream NousResearch issue #19931 (external CLI worker lane) and the closed-unmerged Codex-specific PR #19924; this PR paves the generic mechanism those describe.

A more recent upstream attempt at the same feature is #29777 — its author OK'd a separate implementation (comment). That PR has grown large and drifted from main, so this is a focused, independent take on the same idea.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • hermes_cli/worker_lanes.py (new): process-local WorkerLane registry — register_worker_lane / get_worker_lane / is_worker_lane_assignee / list_worker_lanes / clear_worker_lanes, lane-name normalization, and kanban_worker_env (the shared HERMES_KANBAN_* worker contract). WorkerLane(name, spawn_fn, kind="", max_concurrency=None) with validation.
  • hermes_cli/plugins.py: PluginContext.register_worker_lane(lane, *, replace=False) so a plugin registers a lane at load time.
  • hermes_cli/kanban_db.py: dispatcher integration — _get_worker_lane, _resolve_spawn_fn (explicit → lane → default), a registered lane assignee treated as spawnable in _assignee_is_spawnable / has_spawnable_ready / has_spawnable_review, and per-lane max_concurrency honored in the dispatch loop's effective concurrency cap. Default profile-spawn path unchanged.
  • tests/hermes_cli/test_worker_lanes.py (new): registry (register / normalize / duplicate-reject / validation), spawn resolution precedence, kanban_worker_env contract, spawnable-counting, dispatch routing, and per-lane concurrency cap (dry-run and real-run).

How to Test

pytest tests/hermes_cli/test_worker_lanes.py -q

→ 14 passed.

Behavior check: from a plugin's register(ctx), call ctx.register_worker_lane(WorkerLane(name="my-runner", spawn_fn=my_spawn)); create a task with assignee="my-runner"; run the dispatcher. It resolves my_spawn (not hermes -p), passes the HERMES_KANBAN_* env, and — with max_concurrency set — caps that lane's in-flight workers. An unregistered lane assignee stays skipped_nonspawnable (not silently dropped).

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (feat(kanban):)
  • I searched for existing PRs to avoid duplicates — overlaps the earlier upstream attempt #29777; its author OK'd a separate implementation (comment), and that PR has grown large and fallen behind main
  • My PR contains only changes related to this feature
  • Ran the new suite (pytest tests/hermes_cli/test_worker_lanes.py -q) — 14 passed; please run the full pytest tests/ -q before merge
  • I've added tests for my changes
  • I've tested on my platform: macOS (Darwin 24.6)

Documentation & Housekeeping

  • The lane contract is documented in website/docs/user-guide/features/kanban-worker-lanes.md (existing) — no doc change needed in this PR
  • No new config keys
  • No architecture/workflow doc change required (AGENTS.md unchanged) — or N/A
  • Considered cross-platform impact — pure Python, no OS-specific code
  • Tool descriptions/schemas — N/A (no tool changes)

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown

🔎 Lint report: nikita.barkov/workers vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 11488 on HEAD, 11488 on base (➖ 0)

🆕 New issues (2):

Rule Count
unresolved-import 1
invalid-argument-type 1
First entries
tests/hermes_cli/test_worker_lanes.py:16: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/hermes_cli/test_worker_lanes.py:89: [invalid-argument-type] invalid-argument-type: Argument is incorrect: Expected `(...) -> int | None`, found `Literal["not-callable"]`

✅ Fixed issues (1):

Rule Count
invalid-assignment 1
First entries
hermes_cli/kanban_db.py:7321: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to `def profile_exists(name: str) -> bool`

Unchanged: 6036 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

Let an out-of-tree integration register a custom spawn_fn for a non-Hermes
assignee, so the kanban dispatcher can launch that runtime (e.g. a Codex /
Claude Code CLI runner) directly instead of the default `hermes -p <profile>`
worker. Paves the "external CLI worker lane" path the worker-lanes doc
describes.

- hermes_cli/worker_lanes.py: process-local WorkerLane registry
  (register / get / list / clear, name normalization, kanban_worker_env) with
  an optional per-lane max_concurrency.
- hermes_cli/plugins.py: ctx.register_worker_lane(...) so a plugin can register
  a lane at load time.
- hermes_cli/kanban_db.py: the dispatcher resolves the spawn_fn from the
  registry (_resolve_spawn_fn: an explicit arg wins, then a registered lane,
  then the default Hermes-profile spawn); a registered lane assignee counts as
  spawnable and honors the lane's max_concurrency cap.
- tests/hermes_cli/test_worker_lanes.py: registry, dispatcher-resolution, and
  concurrency-cap coverage.
@nikitaBarkov
nikitaBarkov force-pushed the nikita.barkov/workers branch from 303f344 to 3461018 Compare July 3, 2026 08:08
@nikitaBarkov nikitaBarkov changed the title Add lane workers registry feat(kanban): add worker-lane registry for the dispatcher Jul 3, 2026
nikitaBarkov pushed a commit that referenced this pull request Aug 3, 2026
… a broken chat

A completely unconfigured install previously booted into a working-looking
chat (banner showed model 'unknown'), accepted a message, spun ~30s, then
failed with 'Set OPENROUTER_API_KEY' — a provider the user never chose —
and never offered setup.

- HermesCLI.run() now probes provider readiness at startup (TTY only) and
  offers the shared provider picker (hermes model flow, which fronts Quick
  Setup / Nous Portal OAuth) when nothing is configured. Decline is
  respected; picker state re-syncs into the live CLI so the next turn works
  without a restart.
- New silent probe _runtime_credentials_ready(): no printing, no state
  mutation; handles keyless local endpoints and callable bearer providers.
- The empty-api-key error is provider-aware: names the actual resolved
  provider and points at 'hermes model' / 'hermes setup' instead of
  hardcoding OPENROUTER_API_KEY.
- Banner: unconfigured installs render 'no model configured — run /model'
  in red instead of the silent 'unknown' model slug.

Consumer-onboarding audit finding #2 (sev 5), Aug 2026.
nikitaBarkov pushed a commit that referenced this pull request Aug 3, 2026
A wedged adapter transport (network hang, dead websocket) previously
blocked _check_session_stalls forever: sibling candidates in the same
pass were never evaluated and the watcher stopped ticking. Wrap the
send in asyncio.wait_for (15s); on timeout log a WARNING and do NOT
latch, so the next tick retries. Regression uses a never-resolving fake
adapter and proves the pass completes, a healthy sibling candidate is
still notified in the same pass, and the watcher ticks again
(sabotage-verified against the unbounded send).
nikitaBarkov pushed a commit that referenced this pull request Aug 15, 2026
Addresses both review findings on the remote-gateway download PR:

1. Unbounded buffering (finding #1). fetchBuffer / fetchBufferViaOauthSession
   accumulated the entire response (then copied it again via Buffer.concat)
   before saveGatewayFile even opened the save dialog, so a large gateway file
   could exhaust the native process. Both auth paths now stream: once response
   headers arrive the connect timeout is cleared, the filename is derived, the
   save dialog is shown, and the body is piped to the chosen destination with
   backpressure. A read/write error tears down the stream and unlinks the
   partial file. The byte-moving, data-URL decoding, and filename/path helpers
   are extracted into gateway-file-download.ts so they're unit-testable without
   Electron.

2. No fallback for older gateways (finding #2). saveGatewayFile required the new
   /api/fs/download route. Desktop and the remote gateway update independently,
   so a gateway predating this PR 404s. Added a 404-only compatibility fallback
   to the existing capped /api/fs/read-data-url route (bounded, so it only
   serves smaller files — enough to keep older backends working).

Tests: gateway-file-download.test.ts covers streaming, backpressure,
error-cleanup (unlink on write/response error), data-URL decoding, filename
derivation (incl. traversal reduction), and 404 detection;
gateway-file-download-transport.test.ts asserts both transports stream (no
whole-body Buffer.concat) and that the 404 fallback is wired. Both registered
in the desktop platform test list. Server-side /api/fs/download tests
(streaming + sensitive-file reject) already pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Alexander Prendota (AlexanderPrendota) pushed a commit that referenced this pull request Sep 2, 2026
Review P1 #2. _append_to_transcript_serialized() writes the compression
continuation to child_id BEFORE publishing either _transcript_reroutes or
the _entries update — that ordering is load-bearing for backlog order, so it
must not move. At that moment nothing in the routing index points at the
child, so _db_for_session_id(child_id) missed its scan and fell through to
_db_for_key(None), i.e. the ambient store. The fail-closed guard did not fire
because root is a live handle.

The row therefore targeted root rather than the already-proven parent owner.
With no child row there the append is rejected by the FOREIGN KEY constraint,
the pending queue never drains and the reroute cannot advance; against a
split-brain root the message would instead be written cross-profile.

Record ownership before the mutation instead of moving the publication: a
private _session_owner_hints map carries session_id -> owning key for ids
whose owner is proven but not yet published, consulted by the new
_owner_key_for_session_id() after the index scan misses, and dropped as soon
as routing publishes. Signatures are unchanged, so the existing suites that
stub _append_transcript_message keep working untouched; the map is read
through getattr for stores built via object.__new__.

The regression is physical rather than mocked: an ended compression parent
and a live child that exist only in profiles/fitness/state.db, no active
profile scope, append to the parent, then assert all four effects — the row
lands on the child in the profile store, the pending queue drains, the
reroute and the routing entry advance, and root state.db stays untouched.
Without the hint it fails exactly as the review predicted, on
"FOREIGN KEY constraint failed" against root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Alexander Prendota (AlexanderPrendota) pushed a commit that referenced this pull request Sep 2, 2026
Addresses teknium1's review (NousResearch#64195) finding #2: the multi-rung resolver
needs Electron tests covering precedence, stale-PID rejection, fallback
behavior, and the remote boot path. The pure decision helpers are now
covered by 29 unit tests in `profile-migration.test.ts` (vitest electron
project).

Coverage:
- precedence: legacy > single-running-gateway > state.db heuristic
- stale-PID rejection: recycled PIDs not owned by hermes are dropped
- malformed pid files: JSON parse errors, non-integer PIDs, zero/negative
- scoring edge cases: ancient files (recency floored at 0.1), tiny files
  (size floored at MIN_SIZE), larger DB beats smaller at similar recency
- single-profile fallback: best === 'default' suppresses the write
- no-op cases: preference file already exists, missing profiles root

The remote boot path is verified by code review of the call-site move
(commit preceding this one) — `migrateActiveProfileIfMissing()` now runs
before `primaryProfileKey()` is first read in `startHermes()`.

The pure decision logic that the orchestrator relies on is covered end-
to-end below; this matches the repo's testable-helper pattern (see
`profile-delete-routing.test.ts`).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant