Skip to content

🐛 fix(kanban): never spawn a tool-less worker; bound per-tick spawn burst - #10

Closed
cwest wants to merge 1 commit into
cwest/integrationfrom
topic/fix-toolless-spawn-burst
Closed

🐛 fix(kanban): never spawn a tool-less worker; bound per-tick spawn burst#10
cwest wants to merge 1 commit into
cwest/integrationfrom
topic/fix-toolless-spawn-burst

Conversation

@cwest

@cwest cwest commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Symptom (reproduced)

A dispatcher-spawned kanban worker (Salton, knowledge board) came up with only the base kanban_* coordination tools — no web/shell/git/file — despite its profile correctly declaring a full toolset, then self-blocked ("only kanban_* coordination tools"). Casey: "This block and reason should never happen."

Evidence: gateway.log lines 10902–10954 show the knowledge board stuck 16 ticks then spawned=20 in ONE tick at 15:58:43; the 4 blocked cards (t_68b324b4 / t_15e5879f / t_b4defdf4 / t_87a0dc5f) all spawned at that instant with the tool-less block reason. salton/config.yaml correctly declares [hermes-cli, terminal, file, web, skills, todo, search].

Root cause (whole class)

_default_spawn resolved the worker CLI toolset via _resolve_worker_cli_toolsets, but under the stuck→mass-spawn burst that resolution came up degenerate (None/empty). The old if worker_toolsets: guard then silently launched the worker WITHOUT a --toolsets pin, letting it fall back to a kanban-only surface and burn a full LLM cycle before self-blocking. The single _default_spawn helper backs both the ready and review dispatch call paths, so fixing it there fixes the whole class.

Fix (two layers, smallest-footprint first)

  1. Never spawn tool-less (the invariant). _default_spawn now requires a non-empty resolved CLI toolset and raises when resolution is degenerate. dispatch_once's existing spawn-failure handler records the failure with release_claim=True, so the card is reclaimed to ready for a clean retry instead of running crippled. _resolve_worker_cli_toolsets always recovers at least the kanban lifecycle surface for a real profile home, so None/empty is a genuine failure — not a legitimately tool-less profile.

  2. Bound the per-tick spawn burst (defense in depth). New kanban.max_spawn_per_tick caps how many workers a single tick may launch (ready + review combined), distinct from max_spawn (a live concurrency cap). Prevents a stuck→recovery tick from dumping the whole ready queue at once — the condition under which workers raced into tool-less spawns. Wired through the gateway dispatcher and the CLI dispatch path; unset (None) preserves historical unbounded behavior; invalid/<1 normalizes to None.

Config key the dispatcher loop reads (for the companion hermes-config card): live concurrency cap is kanban.max_spawn; the new per-tick knob is kanban.max_spawn_per_tick.

Tests (behavior contracts, real spawn path vs temp HERMES_HOME)

  • A spawned worker's resolved --toolsets pin equals its profile's declared CLI toolsets (invariant).
  • Degenerate resolution (None and empty-list) → _default_spawn raises and Popen is never reached → card reclaimed, never a kanban-only worker. Red-green verified: reverting the guard makes both tests fail.
  • N > cap ready cards spawn at most cap per tick.
  • The gateway forwards kanban.max_spawn_per_tick to dispatch_once.

Targeted kanban + new tests: 406 passed, 1 skipped. Full tests/hermes_cli/ failures match the pristine integration baseline (178 pre-existing webhook/web_ui ordering-pollution failures, all in files untouched by this diff) — zero new regressions.

PR only — do not deploy. Casey merges and deploys.

…urst

A dispatcher-spawned worker (Salton, knowledge board) came up with ONLY the
base kanban_* coordination tools — no web/shell/git/file — despite its profile
correctly declaring a full toolset, then self-blocked ("only kanban_*
coordination tools"). Root cause: under a stuck->mass-spawn recovery the
dispatcher spawned all 20 ready cards in one tick; _resolve_worker_cli_toolsets
came up degenerate and _default_spawn silently launched the worker WITHOUT a
--toolsets pin (the `if worker_toolsets:` guard), letting it fall back to a
kanban-only surface and waste a full LLM cycle.

Two layers, smallest-footprint first, fixing the whole class (the single
_default_spawn helper covers both the ready and review dispatch call paths):

1. Never spawn tool-less. _default_spawn now REQUIRES a non-empty resolved CLI
   toolset and raises when resolution is degenerate. dispatch_once's existing
   spawn-failure handler records the failure with release_claim=True, so the
   card is reclaimed to `ready` for a clean retry instead of running crippled.
   _resolve_worker_cli_toolsets always recovers at least the kanban lifecycle
   surface for a real profile home, so None/empty is a genuine failure, not a
   legitimately tool-less profile.

2. Bound the per-tick spawn burst. New kanban.max_spawn_per_tick caps how many
   workers a single tick may launch (ready + review combined), distinct from
   max_spawn (a live concurrency cap). Prevents a stuck->recovery tick from
   dumping the whole ready queue at once — the condition under which workers
   raced into tool-less spawns. Wired through the gateway dispatcher and the
   CLI dispatch path; unset preserves historical unbounded behavior.

Behavior-contract tests: a spawned worker's resolved toolset is pinned and a
degenerate resolution reclaims the card (real _default_spawn against a temp
HERMES_HOME, not a mock); N>cap ready cards spawn at most cap per tick; and the
gateway forwards kanban.max_spawn_per_tick to dispatch_once.

@cwest cwest left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the diff against the head SHA and ran the suite in a throwaway clone. The fix is correct and the reclaim path holds up under inspection: _default_spawn raising on a degenerate toolset flows into dispatch_once's existing except handler, which calls _record_spawn_failure(release_claim=True), and below the failure limit that UPDATEs the card back to status='ready' with claim_lock cleared. So a tool-less resolution reclaims for a clean retry instead of launching a crippled worker — exactly what the description claims. The per-tick break is placed before claim_task in both the ready and review loops, and the gateway wires max_spawn_per_tick through with a test asserting the forward.

Test results from the clone at the head SHA:

  • The 7 new tests: all pass.
  • test_kanban_db.py + test_kanban_worker_spawn_toolsets.py: 230 passed.
  • test_kanban_core_functionality.py: 176 passed, 1 skipped.
  • Red-green check: reverting the guard back to if worker_toolsets: makes both _default_spawn toolless tests fail (they reach the fail_popen stub), so the invariant is genuinely covered.

Commit is signed (ED25519, casey@geeknest.com), Conventional + emoji, no AI attribution.

Two small notes inline, neither blocking. Holding the formal approve since this is still a draft.

Comment thread hermes_cli/kanban_db.py
# recovers at least the kanban lifecycle surface for a real profile home,
# so a None/empty result here is a genuine resolution failure, not a
# legitimately tool-less profile.
worker_toolsets = _resolve_worker_cli_toolsets(env.get("HERMES_HOME"))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment above says _resolve_worker_cli_toolsets "always recovers at least the kanban lifecycle surface," but its except branch also returns None (e.g. load_config or _get_platform_tools raising on an exotic config), and that None now lands here as a hard spawn failure. That's the safe direction — reclaim + retry beats launching tool-less, and the consecutive-failures breaker eventually blocks a card that keeps failing — so no change needed. Just flagging that the "always recovers" framing is a bit stronger than the code guarantees: a config-load exception fails the spawn rather than recovering a surface.


import subprocess as _subprocess

def fail_popen(*args, **kwargs): # pragma: no cover - must not be reached

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This E2E asserts the reclaim outcome, which is the right thing to lock down. Worth knowing it isn't a tight guard on the new raise specifically: because Popen is stubbed to throw, the test still passes if you revert default_spawn to the old if worker_toolsets: path — the spawn fails either way and dispatch_once reclaims. The two test_default_spawn_raises* tests in test_kanban_worker_spawn_toolsets.py are what actually pin the raise (they fail red when the guard is reverted), so coverage of the invariant is fine overall.

@cwest

cwest commented Jun 29, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #20, which cherry-picked this fix onto cwest/integration (the fork dev line) and merged. This PR targeted origin/main (upstream mirror), where the fix does not belong. Closing as superseded.

@cwest cwest closed this Jun 29, 2026
@cwest
cwest deleted the topic/fix-toolless-spawn-burst branch June 29, 2026 21:58
cwest pushed a commit that referenced this pull request Jul 26, 2026
…e_check_xsrf pitfalls

Add two pitfalls discovered when running the skill against a fresh
Jupyter server:

- Pitfall #9: When the websocket reply channel hangs on every execute
  even though the kernel actually ran (REST shows execution_state=idle
  and execution_count increments), force zmq transport with
  --transport zmq. The zmq transport uses jupyter_client directly and
  sidesteps the broken websocket layer.

- Pitfall #10: A fresh ServerApp rejects POST /api/sessions with
  "_xsrf argument missing from POST" unless you start it with
  --ServerApp.disable_check_xsrf=True. Needed for REST-only flows
  where no browser/cookie is establishing the XSRF token.
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