Issue #96 — Keyspace-Based Task Distribution with Dynamic Chunking#
Issue Summary#
CipherSwarm's TaskAssignmentService (~18.9 KB) divides attack keyspace across
agents proportional to their benchmark throughput. hash_hive has BullMQ task
queuing and atomic claiming but uses a flat DEFAULT_CHUNK_SIZE = 10_000_000
(see packages/backend/src/services/tasks.ts:12). The chunks ignore agent
speed and fleet composition, so a 100 GH/s GPU and a 10 MH/s CPU receive the
same workload and the slow node bottlenecks the campaign.
Per the issue, this is the single most critical gap versus CipherSwarm.
Problem Statement#
What's already in place (do not re-build):
attacks.keyspace varchar(255)— string storage (some keyspaces exceed
JS Number safety), packages/shared/src/db/schema.ts:373tasks.workRange jsonb—{start, end, total}per chunktasks.progress jsonb— agent-reported progressgenerateTasksForAttack(attackId, opts)— flat 10M chunking in
packages/backend/src/services/tasks.ts:40assignNextTask(agentId)— atomic claim via raw SQL CTE +
FOR UPDATE SKIP LOCKED, packages/backend/src/services/tasks.ts:138
(GOTCHAS.md:39 already documents the Drizzle workaround)agentBenchmarks.speedHs bigint+getAgentBenchmarkForMode(agentId, mode)reassignStaleTasks(thresholdMs)— already runs every 2 min via BullMQhandleTaskFailure— max-3-retry logic shipped- Campaign progress aggregation in SQL (GOTCHAS.md:46)
What's missing:
- Keyspace calculation at attack-creation time.
attacks.keyspaceis
currently populated by callers; there is no service that derives the
total keyspace from the attack's mode, wordlist size, rule count, and
mask length. - Dynamic chunk sizing. Chunks are a flat 10M units regardless of
agent capability. We need a chunk size proportional to either the
median fleet benchmark (at generation time) or the claiming agent's
own benchmark (at assignment time), so each task takes roughly the
same wall time per agent. - Per-task keyspace progress surface.
updateTaskProgressalready
accepts akeyspaceProgressfield but it isn't read elsewhere and
isn't surfaced on the task row consistently. - Dynamic rebalancing on fleet change. When an agent goes
offline,reassignStaleTasksresets the task topendingand lets
the next agent claim the whole remaining chunk. For long chunks
with significant progress already reported, the remaining keyspace
should be re-chunked so a slower agent doesn't inherit an
oversized work range. - Diagnostic logging.
assignNextTaskreturnsnullwithout
exposing why a task wasn't assigned. Operators debugging fleet
utilization need skip-reason traces.
Technical Approach#
Three pure modules + an extension to the existing service layer. KISS:
no new tables, no new BullMQ queues, no preemption (that's #97).
A. Keyspace calculation (services/keyspace.ts, new, ~150 lines)#
A pure function calculateAttackKeyspace(attack, dictionaries) that
returns a bigint-string given:
- Attack
mode(hashcat mode number) - Wordlist row count (from
word_lists.line_countif populated; else
count rows on demand via S3 or DB) - Rulelist row count
- Mask length and charset cardinality (parse the mask string)
Mode-by-mode:
| hashcat attack mode | Formula |
|---|---|
0 (straight) | wordlist.count × max(rules.count, 1) |
1 (combination) | wordlistA.count × wordlistB.count |
3 (mask) | Π(charset_size_per_position) |
6 (hybrid w+m) | wordlist.count × mask_keyspace |
7 (hybrid m+w) | mask_keyspace × wordlist.count |
| other | null → fall back to single-task path |
Return as a string to preserve bigint precision across the JS boundary
and match the existing attacks.keyspace varchar column. Existing
single-task fallback at generateTasksForAttack:51-65 covers the null
case unchanged.
B. Benchmark-aware chunk sizing (services/chunk-sizing.ts, new, ~120 lines)#
Pure helper pickChunkSize({ totalKeyspace, agentBenchmarks, targetSeconds }):
- Target wall-time per chunk: 60 seconds (configurable; tunable later).
- For the fleet's median
speedHs, chunk =speedHs * targetSeconds,
clamped between MIN_CHUNK_SIZE (1,000) and MAX_CHUNK_SIZE
(1,000,000,000), then capped at the remaining total keyspace. - A separate per-attack ceiling on chunk count (MAX_CHUNKS_PER_ATTACK,
100,000 in the current impl) prevents OOM on enormous mask attacks by
lifting the per-chunk floor whentotalKeyspace / chunkSizewould
exceed the cap. - Returns chunk size as
bigint-string.
For generation-time chunking (no specific claimant yet), use the
median benchmark across active agents for the attack's hashcat mode.
For assignment-time re-chunking (rebalance path), use the claiming
agent's own benchmark.
C. Diagnostic logging hooks#
Replace assignNextTask's opaque null return with a discriminated
union: { kind: 'assigned'; task } | { kind: 'skipped'; reason: SkipReason }.
Skip reasons:
agent_not_eligible(offline / not benchmarked / busy)no_matching_capability(no task matches GPU + hashcat mode)no_pending_tasks(project has zeropendingrows for this agent)claim_race_lost(SKIP LOCKED skipped every candidate)
Log each skip at info level with { agentId, projectId, reason }. Keep
the public route contract (returns null or task) so the agent API
surface doesn't break — the discriminated union is internal.
D. Dynamic rebalancing (extend reassignStaleTasks)#
When reassignStaleTasks finds a stale task with non-zero
progress.keyspaceProgress:
- Read the reported
keyspaceProgress(units already completed). - Trim the task's
workRange.starttostart + keyspaceProgress
(or move tofailedwhenkeyspaceProgress >= workRange.total,
covering both the un-acked completion case and true overruns). - If the remaining range is still larger than the fleet's median
benchmark × 60s, split it into smaller follow-up tasks. Otherwise
leave it as one re-pending task.
Touch nothing in the happy claim path. The rebalance only fires for
tasks that were stranded.
E. Plug C and D into the existing assignNextTask and reassignment paths#
Surgical changes to services/tasks.ts:
assignNextTask: callpickChunkSizefor any in-progress
rebalance scenarios; otherwise unchanged.generateTasksForAttack: callcalculateAttackKeyspacewhen
attack.keyspaceis null/empty, andpickChunkSizewith median
benchmark instead ofDEFAULT_CHUNK_SIZE.reassignStaleTasks: invoke the rebalance branch when
progress.keyspaceProgress > 0.
Implementation Plan#
TDD throughout. Tests first, implementation second.
Phase 1 — Keyspace calculation (red → green → refactor)#
- Write
packages/backend/tests/unit/keyspace.test.tscovering all six
mode formulas + the unknown-mode fallback. - Implement
packages/backend/src/services/keyspace.tsuntil tests
green. - Wire into
generateTasksForAttackonly whenattack.keyspaceis
missing.
Phase 2 — Chunk sizing (red → green → refactor)#
- Write
packages/backend/tests/unit/chunk-sizing.test.ts:- Median selection across multiple benchmarks
- Floor and ceiling clamps
- Total-keyspace boundary cases
- Empty benchmark set → falls back to
DEFAULT_CHUNK_SIZE
- Implement
packages/backend/src/services/chunk-sizing.ts. - Replace the hard-coded
DEFAULT_CHUNK_SIZEreference in
generateTasksForAttackwith apickChunkSizecall.
Phase 3 — Diagnostic logging#
- Extend tests in
packages/backend/tests/unit/tasks.test.tsto assert
that the four skip reasons are emitted (verify via mocked
logger.infocalls). - Refactor
assignNextTaskto use the internal discriminated union;
keep the exported contract (AssignedTask | null) stable.
Phase 4 — Dynamic rebalancing#
- Extend
reassignStaleTaskstests to cover:- Stale task with 0% progress → reset to pending unchanged
- Stale task with 40% progress → workRange trimmed
- Stale task with progress
>= workRange.total→ markedfailed - Long remaining range → re-chunked into multiple pending tasks
- Implement the rebalance branch in
reassignStaleTasks.
Phase 5 — Integration smoke + GOTCHAS update#
- Integration assertion in
tests/integration/smoke.test.ts(or new):
campaign with mixed wordlist + rule attack generates chunks whose
sizes vary with the median fleet benchmark. - Add a GOTCHAS bullet on the bigint/string boundary for keyspace
values (don'tNumber.parseIntkeyspaces > 2^53; useBigInt). - Update
spec/tickets/Task_Distribution_&_Assignment.mdcross-link
to point at this issue for the "Advanced keyspace optimization"
out-of-scope item that's now in scope.
Test Plan#
| Layer | File | Coverage |
|---|---|---|
| Unit | tests/unit/keyspace.test.ts (new) | Six hashcat mode formulas + unknown-mode null; mask-charset parser; bigint precision boundary |
| Unit | tests/unit/chunk-sizing.test.ts (new) | Median selection, floor/ceiling, empty-benchmark fallback, full-keyspace cap |
| Unit | tests/unit/tasks.test.ts (extend) | Skip-reason emission for all four reasons; rebalance branch on stale tasks with partial progress; > 100% progress → failed |
| Integration | tests/integration/smoke.test.ts (extend) | Generate tasks for a wordlist+rule attack with mocked benchmarks → chunk count and per-chunk size proportional to fleet median |
Target ≥ 80% coverage on each new file. Each phase ships only when its
test phase is green.
Files to Modify/Create#
New (≤ 300 lines each, KISS):
packages/backend/src/services/keyspace.ts— pure mode-by-mode
keyspace calculatorpackages/backend/src/services/chunk-sizing.ts— pure chunk-size
pickerpackages/backend/tests/unit/keyspace.test.tspackages/backend/tests/unit/chunk-sizing.test.ts
Modify:
packages/backend/src/services/tasks.ts— wire new helpers into
generateTasksForAttack,assignNextTask,reassignStaleTasks;
add skip-reason loggingpackages/backend/tests/unit/tasks.test.ts— coverage for new
branchespackages/backend/tests/integration/smoke.test.ts— proportional
chunking smokeGOTCHAS.md— bigint/keyspace string boundary notespec/tickets/Task_Distribution_&_Assignment.md— cross-link
Schema: No changes. attacks.keyspace varchar(255),
tasks.workRange jsonb, tasks.progress jsonb, and
agent_benchmarks.speed_hs bigint already carry the data we need.
Success Criteria#
Mapped 1:1 to the issue's acceptance criteria:
- Attacks divided into keyspace-based task chunks
calculateAttackKeyspacecomputes the keyspace from attack
metadata at generation time. The computed value is consumed
directly bygenerateTasksForAttackfor chunking. The result is
not persisted back toattacks.keyspaceyet; that follow-up
requires resolving when to store (at attack create vs. at task
generate) and is tracked separately.
- Chunk sizes account for agent benchmark data
pickChunkSizeuses fleet-medianspeedHs * 60s, clamped between
MIN/MAX bounds and capped at MAX_CHUNKS_PER_ATTACK to bound DB-row
materialization. Verified by unit + integration tests.
- Atomic task claiming (no double-assignment)
- Already shipped (
FOR UPDATE SKIP LOCKEDvia raw SQL CTE).
Verified by existing tests.
- Already shipped (
- Progress tracked as keyspace units
tasks.progress.keyspaceProgressis the source of truth across the
assignment, rebalance, and campaign-aggregate paths. The value is
absolute keyspace units cracked within the task's
workRange.total;updateCampaignProgressdivides by total to
derive the [0, 1] fraction the dashboard consumes.
- Remaining keyspace redistributable on fleet changes
reassignStaleTasksrebalance branch trimsworkRange.start
forward by reported progress (single-task re-pend). The
fleet-median split-on-rebalance from this spec's text is a
follow-up; the current branch always re-pends a single task with
the trimmed range.
- Diagnostic logging
assignNextTaskemits one info-leveltask_assignmentlog per
claim attempt with{agentId, projectId, reason}.
Out of Scope#
Lifted verbatim from the issue, plus a few that came up during
research:
- Task preemption — explicitly the next issue (#97).
- Cross-engine routing — JtR-aware keyspace + benchmark
distribution is deferred. - Persistent skip-reason history — log only, don't write a new
table. - Wordlist/rulelist line-count cache — assume
word_lists.line_count
is populated by ingest; out of scope to backfill or recompute. - Adaptive
targetSeconds— start at a fixed 60s constant. Tuning
knob added later if the fleet's actual chunk wall times drift. - Frontend UI for chunk sizing — operators don't see this layer;
it's an internal scheduler concern.
References
- Issue: https://github.com/EvilBit-Labs/hash_hive/issues/96
- CipherSwarm parent: CipherSwarm issue #622 (priority)
- Dep #93 (Agent Benchmarking): shipped —
agent_benchmarkstable +
getAgentBenchmarkForModeare in main. - Blocks #97 (Task Preemption) — preemption needs the chunk-sizing
primitives this issue ships. - Existing spec ticket:
spec/tickets/Task_Distribution_&_Assignment.md
(already shipped; "Advanced keyspace optimization" was deferred there
and is now this issue). - GOTCHAS.md:39 —
FOR UPDATE SKIP LOCKEDworkaround already documented.