feat(pool): dispatch by type across multiple instances #107
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/claude-hooks!107
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "boss/49"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Adds a pool-aware scheduler so
handleIssueAssigned(and every siblingdispatch site) routes by agent type instead of a hardcoded instance
name — a long-running
devtask ondev-alphano longer blocks everyother
devdispatch oncedev-bravoexists.src/pool.tsowninggetWorkersByTypeanddispatchByType.Selection rules: prefer any idle worker (round-robin among idle
candidates), else pick the least-loaded queue (tie-break by the
round-robin cursor). Throws when the type has no registered pool
member — webhook callers should have already resolved the agent and
will log rather than crash.
main.tsexposes the pool API as thin wrappers that feed theservice-global
workersregistry intopool.ts.resetPoolCursoris re-exported for tests.
webhook-handlers.tsand
webhook-ci.tsswapsgetWorker(name)→dispatchByType(type, request):handleIssueAssigned,handleReviewRequested,handleChangesRequested,dispatchRebaseIfNotMergeable,handlePostMergeRebase,dispatchFixCi,dispatchMerge.same
(repo, issue, type)are dropped; cross-type still cancels theprevious type's still-queued task. With one
<type>-defaultinstance per type the observable behaviour matches #92; with a pool
it prevents the pool itself from becoming a dup source.
Tests
src/pool.test.ts— selection unit tests: idle single, idlemultiple (round-robin), all-busy (least-loaded + tie-break by
cursor), no instance registered (throws). Uses in-memory Worker
registry so Bun's cross-file
mock.moduleleak can't shadowdispatchByType.src/webhook-assign-dedup.test.ts— extended mock'sdispatchByTypeto mirror real pool logic and added a "two dev assigns on distinct
issues reach both pool members" test.
src/webhook-ci.test.ts,src/webhook-post-merge.test.ts— mocksupdated to expose
dispatchByTypealongside the legacygetWorker.Test plan
bunx tsc --noEmitbunx biome check src/bun test— 348 pass, 0 failCloses #49
Review: feat(pool): dispatch by type across multiple instances
CI ✅ green on head
15eaaa7(run #1651, 2m53s).All acceptance criteria from #49 are met.
pool.ts — selection logic
Correct. The two-pass scan (idle-first with round-robin, then least-loaded with cursor tie-break) is clean and the algorithm is provably fair over a long tail of dispatches. The decision to take
workersas a parameter rather than importing the module-global Map is the right call — it makespool.test.tshermetic and sidesteps Bun's cross-file mock-module leak that the comment explains.The
picked ?? candidates[0]safety net is dead code (proven by the earlierlength > 0guard), but the inline comment acknowledges this explicitly; leaving it is fine for TypeScript narrowing.main.ts wrappers
Clean re-export pattern.
dispatchByType,getWorkersByType, andresetPoolCursorare thin wrappers that thread the module-globalworkersmap into the pure pool functions. No logic lives here, which keeps the coupling surface minimal.Webhook rewiring
All dispatch sites confirmed rewired:
handleIssueAssigned→dispatchIssueForAgent→dispatchByType(agent.type, ...)✅handleReviewRequested→dispatchByType(agent.type, ...)✅handleChangesRequested→dispatchRebaseIfNotMergeable/dispatchByType(agent.type, ...)✅handleApproved→dispatchRebaseIfNotMergeable/dispatchMerge✅dispatchFixCi→dispatchByType(agent.type, ...)✅dispatchMerge→dispatchByType(MERGE_AGENT_TYPE, ...)✅handlePostMergeRebase→dispatchByType(agent.type, ...)✅Dedup re-keyed by type
The shift from instance-keyed to type-keyed dedup is correct for pool semantics. The
_assignDedupmap (same-type, same-issue: drop) and_lastIssueDispatchmap (cross-type: cancel queued task of previous type) work together cleanly.cancelStaleIssueTaskcorrectly uses the recordedinstancename (not the type) to look up the specific Worker whose queue to splice — exactly right.Tests
pool.test.ts— covers all four AC scenarios: idle-single, idle-multiple (round-robin, each instance hit once), all-busy least-loaded, none-registered (throws). Round-robin cursor reset between cases viabeforeEach. ✅webhook-assign-dedup.test.ts— two dev assigns on distinct issues reach both pool members. ✅webhook-ci.test.ts/webhook-post-merge.test.ts— mocks updated to exposedispatchByType. ✅Minor note (non-blocking)
src/webhook-ci.ts— JSDoc forMERGE_AGENT_TYPEsays "until pool scheduling (A2) lands, that'sboss-default". Pool scheduling (A2) is this PR, so the qualifier is now stale. ThedispatchMergeJSDoc directly below it correctly describes the new behaviour. Consider trimming the constant's comment to just// Agent type that performs merges.in a follow-up.APPROVED. Correct implementation, comprehensive tests, green CI.
Review: feat(pool): dispatch by type across multiple instances
CI ✅ green on head
15eaaa7(run #1651, 2m53s).All acceptance criteria from #49 are met.
pool.ts — selection logic
Correct. The two-pass scan (idle-first with round-robin, then least-loaded with cursor tie-break) is clean and the algorithm is provably fair over a long tail of dispatches. The decision to take
workersas a parameter rather than importing the module-global Map is the right call — it makespool.test.tshermetic and sidesteps Bun's cross-file mock-module leak that the comment explains.The
picked ?? candidates[0]safety net is dead code (proven by the earlierlength > 0guard), but the inline comment acknowledges this explicitly; leaving it is fine for TypeScript narrowing.main.ts wrappers
Clean re-export pattern.
dispatchByType,getWorkersByType, andresetPoolCursorare thin wrappers that thread the module-globalworkersmap into the pure pool functions. No logic lives here, which keeps the coupling surface minimal.Webhook rewiring
All dispatch sites confirmed rewired:
handleIssueAssigned→dispatchIssueForAgent→dispatchByType(agent.type, ...)✅handleReviewRequested→dispatchByType(agent.type, ...)✅handleChangesRequested→dispatchRebaseIfNotMergeable/dispatchByType(agent.type, ...)✅handleApproved→dispatchRebaseIfNotMergeable/dispatchMerge✅dispatchFixCi→dispatchByType(agent.type, ...)✅dispatchMerge→dispatchByType(MERGE_AGENT_TYPE, ...)✅handlePostMergeRebase→dispatchByType(agent.type, ...)✅Dedup re-keyed by type
The shift from instance-keyed to type-keyed dedup is correct for pool semantics. The
_assignDedupmap (same-type, same-issue: drop) and_lastIssueDispatchmap (cross-type: cancel queued task of previous type) work together cleanly.cancelStaleIssueTaskcorrectly uses the recordedinstancename (not the type) to look up the specific Worker whose queue to splice — exactly right.Tests
pool.test.ts— covers all four AC scenarios: idle-single, idle-multiple (round-robin, each instance hit once), all-busy least-loaded, none-registered (throws). Round-robin cursor reset between cases viabeforeEach. ✅webhook-assign-dedup.test.ts— two dev assigns on distinct issues reach both pool members. ✅webhook-ci.test.ts/webhook-post-merge.test.ts— mocks updated to exposedispatchByType. ✅Minor note (non-blocking)
src/webhook-ci.ts— JSDoc forMERGE_AGENT_TYPEsays "until pool scheduling (A2) lands, that'sboss-default". Pool scheduling (A2) is this PR, so the qualifier is now stale. ThedispatchMergeJSDoc directly below it correctly describes the new behaviour. Consider trimming the constant's comment to just// Agent type that performs merges.in a follow-up.APPROVED. Correct implementation, comprehensive tests, green CI.