Agents: pool scheduler — dispatch by type across multiple instances #49

Closed
opened 2026-04-18 14:59:48 +00:00 by claude-desktop · 0 comments
Collaborator

User story

As a maintainer, I want the webhook to dispatch by type instead of by hardcoded agent name, picking an idle instance from the type's pool (or queuing on the type's aggregate queue), so that a long-running dev task stops blocking every other dev dispatch.

Context

Today handleIssueAssigned does getWorker(assignee.login) — one worker per name. With A1 landed, a type can have N instances. The scheduler must:

  • Know which worker instances serve which type.
  • Prefer an idle instance over enqueueing on a busy one.
  • Distribute fairly when all are busy (round-robin is fine for v1).

Session resume (<type>:<repo>:<issue> key per A1) means any pool member can pick up a resumed dispatch — no sticky routing needed.

Acceptance criteria

Worker registry

  • getWorker(name) stays (one worker per instance).
  • Add getWorkersByType(type): Worker[] that returns all registered workers whose instance type matches.
  • Worker registration reads from resolveAgent() (A1) and creates one Worker per SQLite row at startup.

Pool selection

  • New dispatchByType(type, request): Promise<string> in the worker layer:
    1. candidates = getWorkersByType(type). If empty: return a clear error (no instance for type).
    2. Prefer any idle worker (worker.current === null && worker.queue.length === 0). If multiple, pick round-robin (keep a per-type cursor).
    3. No idle candidate: pick the least-loaded (min queue.length). Tie-break by round-robin cursor.
    4. Call worker.enqueue(request) and return the task id.

Webhook rewiring

  • All dispatch sites in src/webhook-handlers.ts swap from getWorker(<name>) to dispatchByType(<type>, request):
    • handleIssueAssigned — assignee login is the Forgejo user (e.g. dev), which is now the type. Use it directly.
    • handleReviewRequested, handleChangesRequested, handleApproved, handleStatusEvent dispatchFixCi — the target type is known from the dispatch context ("reviewer", "boss", etc.). Replace hardcoded lookup.
  • dispatchMerge (from #42) uses dispatchByType("boss", ...).

Queueing semantics

  • Each Worker keeps its own FIFO queue (as today). The pool is just "pick which worker to enqueue on." No shared queue between workers.
  • A dispatch that lands on instance dev-alpha's queue sticks there — it's not re-balanced if dev-bravo later becomes idle. Re-balancing is a non-goal.

Tests

  • dispatchByType — idle single, idle multiple (round-robin), all busy (least-loaded), none registered (error).
  • Webhook dispatch ignores instance name and honours type only — verify by registering dev-alpha + dev-bravo and confirming handleIssueAssigned with assignee.login === "dev" can land on either.

Out of scope

  • Cross-instance work stealing / rebalancing.
  • Session affinity (same pool member for a given (type, repo, issue)) — covered by the <type>:<repo>:<issue> session key.
  • Label-aware routing (see A3).
  • Priority queues.

References

  • Tracking issue: #47.
  • Existing dispatch: src/webhook-handlers.ts — search for getWorker(.
  • Worker class: src/worker.ts.

Dependencies

  • Blocked by: A1.
  • Blocks: A3, A6.
  • Branch off: main (after A1 lands).
## User story As a **maintainer**, I want the webhook to dispatch by **type** instead of by hardcoded agent name, picking an idle instance from the type's pool (or queuing on the type's aggregate queue), so that a long-running `dev` task stops blocking every other `dev` dispatch. ## Context Today `handleIssueAssigned` does `getWorker(assignee.login)` — one worker per name. With A1 landed, a type can have N instances. The scheduler must: - Know which worker instances serve which type. - Prefer an idle instance over enqueueing on a busy one. - Distribute fairly when all are busy (round-robin is fine for v1). Session resume (`<type>:<repo>:<issue>` key per A1) means any pool member can pick up a resumed dispatch — no sticky routing needed. ## Acceptance criteria ### Worker registry - [ ] `getWorker(name)` stays (one worker per instance). - [ ] Add `getWorkersByType(type): Worker[]` that returns all registered workers whose instance `type` matches. - [ ] Worker registration reads from `resolveAgent()` (A1) and creates one `Worker` per SQLite row at startup. ### Pool selection - [ ] New `dispatchByType(type, request): Promise<string>` in the worker layer: 1. `candidates = getWorkersByType(type)`. If empty: return a clear error (no instance for type). 2. Prefer any idle worker (`worker.current === null && worker.queue.length === 0`). If multiple, pick round-robin (keep a per-type cursor). 3. No idle candidate: pick the least-loaded (min `queue.length`). Tie-break by round-robin cursor. 4. Call `worker.enqueue(request)` and return the task id. ### Webhook rewiring - [ ] All dispatch sites in `src/webhook-handlers.ts` swap from `getWorker(<name>)` to `dispatchByType(<type>, request)`: - `handleIssueAssigned` — assignee login is the **Forgejo user** (e.g. `dev`), which is now the **type**. Use it directly. - `handleReviewRequested`, `handleChangesRequested`, `handleApproved`, `handleStatusEvent` `dispatchFixCi` — the target type is known from the dispatch context (`"reviewer"`, `"boss"`, etc.). Replace hardcoded lookup. - [ ] `dispatchMerge` (from #42) uses `dispatchByType("boss", ...)`. ### Queueing semantics - [ ] Each `Worker` keeps its own FIFO queue (as today). The pool is just "pick which worker to enqueue on." No shared queue between workers. - [ ] A dispatch that lands on instance `dev-alpha`'s queue sticks there — it's not re-balanced if `dev-bravo` later becomes idle. Re-balancing is a non-goal. ### Tests - [ ] `dispatchByType` — idle single, idle multiple (round-robin), all busy (least-loaded), none registered (error). - [ ] Webhook dispatch ignores instance *name* and honours type only — verify by registering `dev-alpha` + `dev-bravo` and confirming `handleIssueAssigned` with `assignee.login === "dev"` can land on either. ## Out of scope - Cross-instance work stealing / rebalancing. - Session affinity (same pool member for a given `(type, repo, issue)`) — covered by the `<type>:<repo>:<issue>` session key. - Label-aware routing (see **A3**). - Priority queues. ## References - Tracking issue: #47. - Existing dispatch: `src/webhook-handlers.ts` — search for `getWorker(`. - `Worker` class: `src/worker.ts`. ## Dependencies - **Blocked by:** A1. - **Blocks:** A3, A6. - **Branch off:** `main` (after A1 lands).
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks#49
No description provided.