@sparkleideas/claims 3.5.2-patch.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,807 @@
1
+ /**
2
+ * Work Stealing Service - Application Layer
3
+ *
4
+ * Handles work stealing to maximize swarm throughput by redistributing
5
+ * work from stale, blocked, or overloaded agents to available ones.
6
+ *
7
+ * @module v3/claims/application/work-stealing-service
8
+ */
9
+
10
+ import { randomUUID } from 'crypto';
11
+ import {
12
+ type IssueId,
13
+ type Claimant,
14
+ type AgentType,
15
+ type StealableInfo,
16
+ type StealableReason,
17
+ type StealResult,
18
+ type StealErrorCode,
19
+ type ContestInfo,
20
+ type ContestResolution,
21
+ type WorkStealingConfig,
22
+ type IssueClaimWithStealing,
23
+ type IIssueClaimRepository,
24
+ type IWorkStealingEventBus,
25
+ type WorkStealingEvent,
26
+ type WorkStealingEventType,
27
+ type IssueMarkedStealableEvent,
28
+ type IssueStolenEvent,
29
+ type StealContestedEvent,
30
+ type StealContestResolvedEvent,
31
+ DEFAULT_WORK_STEALING_CONFIG,
32
+ } from '../domain/types.js';
33
+
34
+ // =============================================================================
35
+ // Service Interface
36
+ // =============================================================================
37
+
38
+ /**
39
+ * Work Stealing Service Interface
40
+ */
41
+ export interface IWorkStealingService {
42
+ /** Mark work as stealable */
43
+ markStealable(issueId: IssueId, info: StealableInfo): Promise<void>;
44
+
45
+ /** Steal work from another agent */
46
+ steal(issueId: IssueId, stealer: Claimant): Promise<StealResult>;
47
+
48
+ /** Get list of stealable issues */
49
+ getStealable(agentType?: AgentType): Promise<IssueClaimWithStealing[]>;
50
+
51
+ /** Contest a steal (original owner wants it back) */
52
+ contestSteal(issueId: IssueId, originalClaimant: Claimant, reason: string): Promise<void>;
53
+
54
+ /** Resolve contest (queen/human decides) */
55
+ resolveContest(issueId: IssueId, winner: Claimant, reason: string): Promise<void>;
56
+
57
+ /** Auto-detect stealable work based on config thresholds */
58
+ detectStaleWork(config: WorkStealingConfig): Promise<IssueClaimWithStealing[]>;
59
+
60
+ /** Auto-mark stealable work based on config thresholds */
61
+ autoMarkStealable(config: WorkStealingConfig): Promise<number>;
62
+ }
63
+
64
+ // =============================================================================
65
+ // Default Event Bus Implementation
66
+ // =============================================================================
67
+
68
+ /**
69
+ * Simple in-memory event bus for work stealing events
70
+ */
71
+ export class InMemoryWorkStealingEventBus implements IWorkStealingEventBus {
72
+ private handlers: Map<WorkStealingEventType | '*', Set<(event: WorkStealingEvent) => void | Promise<void>>> = new Map();
73
+ private history: WorkStealingEvent[] = [];
74
+ private maxHistorySize: number;
75
+
76
+ constructor(options: { maxHistorySize?: number } = {}) {
77
+ this.maxHistorySize = options.maxHistorySize ?? 1000;
78
+ }
79
+
80
+ async emit(event: WorkStealingEvent): Promise<void> {
81
+ this.addToHistory(event);
82
+
83
+ const typeHandlers = this.handlers.get(event.type) ?? new Set();
84
+ const allHandlers = this.handlers.get('*') ?? new Set();
85
+
86
+ const promises: Promise<void>[] = [];
87
+
88
+ for (const handler of typeHandlers) {
89
+ promises.push(this.safeExecute(handler, event));
90
+ }
91
+
92
+ for (const handler of allHandlers) {
93
+ promises.push(this.safeExecute(handler, event));
94
+ }
95
+
96
+ await Promise.all(promises);
97
+ }
98
+
99
+ subscribe(
100
+ eventType: WorkStealingEventType,
101
+ handler: (event: WorkStealingEvent) => void | Promise<void>
102
+ ): () => void {
103
+ if (!this.handlers.has(eventType)) {
104
+ this.handlers.set(eventType, new Set());
105
+ }
106
+
107
+ const handlers = this.handlers.get(eventType)!;
108
+ handlers.add(handler);
109
+
110
+ return () => {
111
+ handlers.delete(handler);
112
+ };
113
+ }
114
+
115
+ subscribeAll(handler: (event: WorkStealingEvent) => void | Promise<void>): () => void {
116
+ if (!this.handlers.has('*')) {
117
+ this.handlers.set('*', new Set());
118
+ }
119
+
120
+ const handlers = this.handlers.get('*')!;
121
+ handlers.add(handler);
122
+
123
+ return () => {
124
+ handlers.delete(handler);
125
+ };
126
+ }
127
+
128
+ getHistory(filter?: { types?: WorkStealingEventType[]; limit?: number }): WorkStealingEvent[] {
129
+ let events = [...this.history];
130
+
131
+ if (filter?.types?.length) {
132
+ events = events.filter(e => filter.types!.includes(e.type));
133
+ }
134
+
135
+ if (filter?.limit) {
136
+ events = events.slice(-filter.limit);
137
+ }
138
+
139
+ return events;
140
+ }
141
+
142
+ private addToHistory(event: WorkStealingEvent): void {
143
+ this.history.push(event);
144
+
145
+ if (this.history.length > this.maxHistorySize) {
146
+ this.history = this.history.slice(-Math.floor(this.maxHistorySize / 2));
147
+ }
148
+ }
149
+
150
+ private async safeExecute(
151
+ handler: (event: WorkStealingEvent) => void | Promise<void>,
152
+ event: WorkStealingEvent
153
+ ): Promise<void> {
154
+ try {
155
+ await handler(event);
156
+ } catch (err) {
157
+ console.error(`Work stealing event handler error for ${event.type}:`, err);
158
+ }
159
+ }
160
+ }
161
+
162
+ // =============================================================================
163
+ // Work Stealing Service Implementation
164
+ // =============================================================================
165
+
166
+ /**
167
+ * Work Stealing Service
168
+ *
169
+ * Implements work stealing algorithms to maximize swarm throughput by
170
+ * redistributing work from stale, blocked, or overloaded agents.
171
+ */
172
+ export class WorkStealingService implements IWorkStealingService {
173
+ private readonly config: WorkStealingConfig;
174
+
175
+ constructor(
176
+ private readonly repository: IIssueClaimRepository,
177
+ private readonly eventBus: IWorkStealingEventBus,
178
+ config: Partial<WorkStealingConfig> = {}
179
+ ) {
180
+ this.config = { ...DEFAULT_WORK_STEALING_CONFIG, ...config };
181
+ }
182
+
183
+ // ===========================================================================
184
+ // Mark Stealable
185
+ // ===========================================================================
186
+
187
+ /**
188
+ * Mark work as stealable with the given reason
189
+ */
190
+ async markStealable(issueId: IssueId, info: StealableInfo): Promise<void> {
191
+ const claim = await this.repository.findByIssueId(issueId);
192
+
193
+ if (!claim) {
194
+ throw new Error(`Claim not found for issue: ${issueId}`);
195
+ }
196
+
197
+ // Check if already stealable
198
+ if (claim.stealInfo) {
199
+ return; // Already marked
200
+ }
201
+
202
+ // Check grace period protection
203
+ if (this.isInGracePeriod(claim)) {
204
+ throw new Error(`Claim is still in grace period`);
205
+ }
206
+
207
+ // Check progress protection
208
+ if (this.isProtectedByProgress(claim)) {
209
+ throw new Error(`Claim is protected by progress (${claim.progress}%)`);
210
+ }
211
+
212
+ // Update claim with stealable info
213
+ const now = new Date();
214
+ claim.stealInfo = {
215
+ ...info,
216
+ markedAt: now,
217
+ };
218
+ claim.stealableAt = now;
219
+
220
+ await this.repository.update(claim);
221
+
222
+ // Emit event
223
+ await this.emitMarkedStealableEvent(claim, info);
224
+ }
225
+
226
+ // ===========================================================================
227
+ // Steal
228
+ // ===========================================================================
229
+
230
+ /**
231
+ * Steal work from another agent
232
+ */
233
+ async steal(issueId: IssueId, stealer: Claimant): Promise<StealResult> {
234
+ const claim = await this.repository.findByIssueId(issueId);
235
+
236
+ // Validate claim exists
237
+ if (!claim) {
238
+ return this.stealError('ISSUE_NOT_FOUND', `Claim not found for issue: ${issueId}`);
239
+ }
240
+
241
+ // Check if stealable
242
+ if (!claim.stealInfo) {
243
+ return this.stealError('NOT_STEALABLE', 'Issue is not marked as stealable');
244
+ }
245
+
246
+ // Check if there's a pending contest
247
+ if (claim.contestInfo && !claim.contestInfo.resolution) {
248
+ return this.stealError('CONTEST_PENDING', 'A contest is pending for this issue');
249
+ }
250
+
251
+ // Check grace period
252
+ if (this.isInGracePeriod(claim)) {
253
+ return this.stealError('IN_GRACE_PERIOD', 'Claim is still in grace period');
254
+ }
255
+
256
+ // Check progress protection
257
+ if (this.isProtectedByProgress(claim)) {
258
+ return this.stealError('PROTECTED_BY_PROGRESS', `Claim is protected by progress (${claim.progress}%)`);
259
+ }
260
+
261
+ // Check cross-type stealing rules
262
+ const stealerType = this.getAgentType(stealer);
263
+ const ownerType = this.getAgentType(claim.claimant);
264
+
265
+ if (!this.canStealCrossType(stealerType, ownerType, claim.stealInfo)) {
266
+ return this.stealError('CROSS_TYPE_NOT_ALLOWED', `${stealerType} cannot steal from ${ownerType}`);
267
+ }
268
+
269
+ // Check if stealer is overloaded
270
+ const stealerClaimCount = await this.repository.countByAgentId(stealer.id);
271
+ if (stealerClaimCount >= this.config.overloadThreshold) {
272
+ return this.stealError('STEALER_OVERLOADED', `Stealer has too many claims (${stealerClaimCount})`);
273
+ }
274
+
275
+ // Perform the steal
276
+ const previousClaimant = { ...claim.claimant };
277
+ const previousStealInfo = { ...claim.stealInfo };
278
+ const now = new Date();
279
+ const contestWindowEndsAt = new Date(now.getTime() + this.config.contestWindowMinutes * 60 * 1000);
280
+
281
+ // Update claim with new owner
282
+ claim.claimant = stealer;
283
+ claim.stealInfo = undefined;
284
+ claim.stealableAt = undefined;
285
+ claim.lastActivityAt = now;
286
+ claim.contestInfo = {
287
+ contestedAt: now,
288
+ contestedBy: previousClaimant,
289
+ stolenBy: stealer,
290
+ reason: '', // Will be set if contested
291
+ windowEndsAt: contestWindowEndsAt,
292
+ };
293
+
294
+ await this.repository.update(claim);
295
+
296
+ // Emit stolen event
297
+ await this.emitStolenEvent(claim, previousClaimant, stealer, previousStealInfo, contestWindowEndsAt);
298
+
299
+ return {
300
+ success: true,
301
+ claim,
302
+ previousClaimant,
303
+ contestWindowEndsAt,
304
+ };
305
+ }
306
+
307
+ // ===========================================================================
308
+ // Get Stealable
309
+ // ===========================================================================
310
+
311
+ /**
312
+ * Get list of stealable issues, optionally filtered by agent type
313
+ */
314
+ async getStealable(agentType?: AgentType): Promise<IssueClaimWithStealing[]> {
315
+ const stealableClaims = await this.repository.findStealable(agentType);
316
+
317
+ // Filter out claims that are protected or in grace period
318
+ return stealableClaims.filter(claim => {
319
+ if (!claim.stealInfo) return false;
320
+ if (this.isInGracePeriod(claim)) return false;
321
+ if (this.isProtectedByProgress(claim)) return false;
322
+
323
+ // Check cross-type restrictions if agentType is specified
324
+ if (agentType && claim.stealInfo.allowedStealerTypes) {
325
+ if (!claim.stealInfo.allowedStealerTypes.includes(agentType)) {
326
+ return false;
327
+ }
328
+ }
329
+
330
+ return true;
331
+ });
332
+ }
333
+
334
+ // ===========================================================================
335
+ // Contest Steal
336
+ // ===========================================================================
337
+
338
+ /**
339
+ * Contest a steal (original owner wants the work back)
340
+ */
341
+ async contestSteal(issueId: IssueId, originalClaimant: Claimant, reason: string): Promise<void> {
342
+ const claim = await this.repository.findByIssueId(issueId);
343
+
344
+ if (!claim) {
345
+ throw new Error(`Claim not found for issue: ${issueId}`);
346
+ }
347
+
348
+ // Check if there's a valid contest window
349
+ if (!claim.contestInfo) {
350
+ throw new Error('No steal to contest - issue was not recently stolen');
351
+ }
352
+
353
+ if (claim.contestInfo.resolution) {
354
+ throw new Error('Contest has already been resolved');
355
+ }
356
+
357
+ const now = new Date();
358
+ if (now > claim.contestInfo.windowEndsAt) {
359
+ throw new Error('Contest window has expired');
360
+ }
361
+
362
+ // Verify the contester was the original owner
363
+ if (claim.contestInfo.contestedBy.id !== originalClaimant.id) {
364
+ throw new Error('Only the original claimant can contest the steal');
365
+ }
366
+
367
+ // Update contest info with reason
368
+ claim.contestInfo.reason = reason;
369
+ claim.contestInfo.contestedAt = now;
370
+
371
+ await this.repository.update(claim);
372
+
373
+ // Emit contest event
374
+ await this.emitContestEvent(claim);
375
+ }
376
+
377
+ // ===========================================================================
378
+ // Resolve Contest
379
+ // ===========================================================================
380
+
381
+ /**
382
+ * Resolve a contest (queen or human decides the winner)
383
+ */
384
+ async resolveContest(issueId: IssueId, winner: Claimant, reason: string): Promise<void> {
385
+ const claim = await this.repository.findByIssueId(issueId);
386
+
387
+ if (!claim) {
388
+ throw new Error(`Claim not found for issue: ${issueId}`);
389
+ }
390
+
391
+ if (!claim.contestInfo) {
392
+ throw new Error('No contest to resolve');
393
+ }
394
+
395
+ if (claim.contestInfo.resolution) {
396
+ throw new Error('Contest has already been resolved');
397
+ }
398
+
399
+ const now = new Date();
400
+ const resolvedBy = this.determineResolver(winner, claim.contestInfo);
401
+
402
+ // Create resolution
403
+ const resolution: ContestResolution = {
404
+ resolvedAt: now,
405
+ winner,
406
+ resolvedBy,
407
+ reason,
408
+ };
409
+
410
+ claim.contestInfo.resolution = resolution;
411
+
412
+ // Update claimant if the original owner won
413
+ if (winner.id === claim.contestInfo.contestedBy.id) {
414
+ claim.claimant = winner;
415
+ }
416
+
417
+ claim.lastActivityAt = now;
418
+
419
+ await this.repository.update(claim);
420
+
421
+ // Emit resolution event
422
+ await this.emitContestResolvedEvent(claim, resolution);
423
+ }
424
+
425
+ // ===========================================================================
426
+ // Detect Stale Work
427
+ // ===========================================================================
428
+
429
+ /**
430
+ * Detect stale work based on config thresholds
431
+ */
432
+ async detectStaleWork(config: WorkStealingConfig): Promise<IssueClaimWithStealing[]> {
433
+ const allClaims = await this.repository.findAll();
434
+ const now = new Date();
435
+ const staleThresholdMs = config.staleThresholdMinutes * 60 * 1000;
436
+ const blockedThresholdMs = config.blockedThresholdMinutes * 60 * 1000;
437
+
438
+ const staleClaims: IssueClaimWithStealing[] = [];
439
+
440
+ for (const claim of allClaims) {
441
+ // Skip if already stealable
442
+ if (claim.stealInfo) continue;
443
+
444
+ // Skip if in grace period
445
+ if (this.isInGracePeriodWithConfig(claim, config)) continue;
446
+
447
+ // Skip if protected by progress
448
+ if (this.isProtectedByProgressWithConfig(claim, config)) continue;
449
+
450
+ // Check for stale claims (no activity)
451
+ const timeSinceActivity = now.getTime() - new Date(claim.lastActivityAt).getTime();
452
+ if (timeSinceActivity > staleThresholdMs) {
453
+ staleClaims.push(claim);
454
+ continue;
455
+ }
456
+
457
+ // Check for blocked claims
458
+ if (claim.status === 'pending_handoff' && claim.blockedAt) {
459
+ const timeSinceBlocked = now.getTime() - new Date(claim.blockedAt).getTime();
460
+ if (timeSinceBlocked > blockedThresholdMs) {
461
+ staleClaims.push(claim);
462
+ continue;
463
+ }
464
+ }
465
+ }
466
+
467
+ // Check for overloaded agents
468
+ const agentClaimCounts = new Map<string, IssueClaimWithStealing[]>();
469
+ for (const claim of allClaims) {
470
+ const agentId = claim.claimant.id;
471
+ if (!agentClaimCounts.has(agentId)) {
472
+ agentClaimCounts.set(agentId, []);
473
+ }
474
+ agentClaimCounts.get(agentId)!.push(claim);
475
+ }
476
+
477
+ for (const [_agentId, claims] of agentClaimCounts) {
478
+ if (claims.length > config.overloadThreshold) {
479
+ // Sort by progress (lowest first) and mark the lowest priority as stealable
480
+ const sortedClaims = claims
481
+ .filter(c => !c.stealInfo && !staleClaims.includes(c))
482
+ .sort((a, b) => a.progress - b.progress);
483
+
484
+ if (sortedClaims.length > 0) {
485
+ staleClaims.push(sortedClaims[0]);
486
+ }
487
+ }
488
+ }
489
+
490
+ return staleClaims;
491
+ }
492
+
493
+ // ===========================================================================
494
+ // Auto Mark Stealable
495
+ // ===========================================================================
496
+
497
+ /**
498
+ * Auto-mark stealable work based on config thresholds
499
+ */
500
+ async autoMarkStealable(config: WorkStealingConfig): Promise<number> {
501
+ const staleClaims = await this.detectStaleWork(config);
502
+ const now = new Date();
503
+ let markedCount = 0;
504
+
505
+ for (const claim of staleClaims) {
506
+ const reason = this.determineStaleReason(claim, config, now);
507
+ const stealInfo: StealableInfo = {
508
+ reason,
509
+ markedAt: now,
510
+ originalProgress: claim.progress,
511
+ allowedStealerTypes: this.getAllowedStealerTypes(claim.claimant, config),
512
+ };
513
+
514
+ try {
515
+ await this.markStealable(claim.issueId, stealInfo);
516
+ markedCount++;
517
+ } catch (err) {
518
+ // Log but don't fail - some claims may be protected
519
+ console.warn(`Failed to mark claim ${claim.id} as stealable:`, err);
520
+ }
521
+ }
522
+
523
+ return markedCount;
524
+ }
525
+
526
+ // ===========================================================================
527
+ // Private Helper Methods
528
+ // ===========================================================================
529
+
530
+ /**
531
+ * Check if claim is in grace period
532
+ */
533
+ private isInGracePeriod(claim: IssueClaimWithStealing): boolean {
534
+ return this.isInGracePeriodWithConfig(claim, this.config);
535
+ }
536
+
537
+ /**
538
+ * Check if claim is in grace period with specific config
539
+ */
540
+ private isInGracePeriodWithConfig(claim: IssueClaimWithStealing, config: WorkStealingConfig): boolean {
541
+ const gracePeriodMs = config.gracePeriodMinutes * 60 * 1000;
542
+ const now = new Date();
543
+ const claimedAt = new Date(claim.claimedAt);
544
+ return now.getTime() - claimedAt.getTime() < gracePeriodMs;
545
+ }
546
+
547
+ /**
548
+ * Check if claim is protected by progress
549
+ */
550
+ private isProtectedByProgress(claim: IssueClaimWithStealing): boolean {
551
+ return this.isProtectedByProgressWithConfig(claim, this.config);
552
+ }
553
+
554
+ /**
555
+ * Check if claim is protected by progress with specific config
556
+ */
557
+ private isProtectedByProgressWithConfig(claim: IssueClaimWithStealing, config: WorkStealingConfig): boolean {
558
+ return claim.progress >= config.minProgressToProtect;
559
+ }
560
+
561
+ /**
562
+ * Get agent type from claimant
563
+ */
564
+ private getAgentType(claimant: Claimant): AgentType {
565
+ // Try to extract agent type from specializations or capabilities
566
+ const typeKeywords: AgentType[] = ['coder', 'debugger', 'tester', 'reviewer', 'researcher', 'planner', 'architect', 'coordinator'];
567
+
568
+ for (const keyword of typeKeywords) {
569
+ if (claimant.specializations?.includes(keyword)) {
570
+ return keyword;
571
+ }
572
+ if (claimant.capabilities?.includes(keyword)) {
573
+ return keyword;
574
+ }
575
+ if (claimant.name.toLowerCase().includes(keyword)) {
576
+ return keyword;
577
+ }
578
+ }
579
+
580
+ // Default to coder if no type can be determined
581
+ return 'coder';
582
+ }
583
+
584
+ /**
585
+ * Check if cross-type stealing is allowed
586
+ */
587
+ private canStealCrossType(
588
+ stealerType: AgentType,
589
+ ownerType: AgentType,
590
+ stealInfo: StealableInfo
591
+ ): boolean {
592
+ // Same type can always steal
593
+ if (stealerType === ownerType) return true;
594
+
595
+ // Check if cross-type stealing is enabled
596
+ if (!this.config.allowCrossTypeSteal) return false;
597
+
598
+ // Check if there are specific allowed types
599
+ if (stealInfo.allowedStealerTypes) {
600
+ return stealInfo.allowedStealerTypes.includes(stealerType);
601
+ }
602
+
603
+ // Check cross-type steal rules
604
+ for (const [type1, type2] of this.config.crossTypeStealRules) {
605
+ if (
606
+ (stealerType === type1 && ownerType === type2) ||
607
+ (stealerType === type2 && ownerType === type1)
608
+ ) {
609
+ return true;
610
+ }
611
+ }
612
+
613
+ return false;
614
+ }
615
+
616
+ /**
617
+ * Get allowed stealer types for a claimant
618
+ */
619
+ private getAllowedStealerTypes(claimant: Claimant, config: WorkStealingConfig): AgentType[] | undefined {
620
+ if (!config.allowCrossTypeSteal) return undefined;
621
+
622
+ const ownerType = this.getAgentType(claimant);
623
+ const allowedTypes: AgentType[] = [ownerType]; // Same type always allowed
624
+
625
+ for (const [type1, type2] of config.crossTypeStealRules) {
626
+ if (ownerType === type1) allowedTypes.push(type2);
627
+ if (ownerType === type2) allowedTypes.push(type1);
628
+ }
629
+
630
+ return [...new Set(allowedTypes)];
631
+ }
632
+
633
+ /**
634
+ * Determine the stale reason for a claim
635
+ */
636
+ private determineStaleReason(
637
+ claim: IssueClaimWithStealing,
638
+ config: WorkStealingConfig,
639
+ now: Date
640
+ ): StealableReason {
641
+ const staleThresholdMs = config.staleThresholdMinutes * 60 * 1000;
642
+ const blockedThresholdMs = config.blockedThresholdMinutes * 60 * 1000;
643
+
644
+ // Check if blocked
645
+ if (claim.status === 'pending_handoff' && claim.blockedAt) {
646
+ const timeSinceBlocked = now.getTime() - new Date(claim.blockedAt).getTime();
647
+ if (timeSinceBlocked > blockedThresholdMs) {
648
+ return 'blocked';
649
+ }
650
+ }
651
+
652
+ // Check if stale
653
+ const timeSinceActivity = now.getTime() - new Date(claim.lastActivityAt).getTime();
654
+ if (timeSinceActivity > staleThresholdMs) {
655
+ return 'stale';
656
+ }
657
+
658
+ // Default to overloaded
659
+ return 'overloaded';
660
+ }
661
+
662
+ /**
663
+ * Determine who resolved the contest
664
+ */
665
+ private determineResolver(
666
+ winner: Claimant,
667
+ contestInfo: ContestInfo
668
+ ): 'queen' | 'human' | 'timeout' {
669
+ const now = new Date();
670
+
671
+ // Check if window expired (timeout)
672
+ if (now > contestInfo.windowEndsAt) {
673
+ return 'timeout';
674
+ }
675
+
676
+ // Check if resolved by human
677
+ if (winner.type === 'human') {
678
+ return 'human';
679
+ }
680
+
681
+ // Default to queen (coordinator)
682
+ return 'queen';
683
+ }
684
+
685
+ /**
686
+ * Create a steal error result
687
+ */
688
+ private stealError(errorCode: StealErrorCode, error: string): StealResult {
689
+ return {
690
+ success: false,
691
+ error,
692
+ errorCode,
693
+ };
694
+ }
695
+
696
+ // ===========================================================================
697
+ // Event Emission
698
+ // ===========================================================================
699
+
700
+ /**
701
+ * Emit IssueMarkedStealable event
702
+ */
703
+ private async emitMarkedStealableEvent(
704
+ claim: IssueClaimWithStealing,
705
+ info: StealableInfo
706
+ ): Promise<void> {
707
+ const event: IssueMarkedStealableEvent = {
708
+ id: `evt-${randomUUID()}`,
709
+ type: 'IssueMarkedStealable',
710
+ timestamp: new Date(),
711
+ issueId: claim.issueId,
712
+ claimId: claim.id,
713
+ payload: {
714
+ info,
715
+ currentClaimant: claim.claimant,
716
+ claim,
717
+ },
718
+ };
719
+
720
+ await this.eventBus.emit(event);
721
+ }
722
+
723
+ /**
724
+ * Emit IssueStolen event
725
+ */
726
+ private async emitStolenEvent(
727
+ claim: IssueClaimWithStealing,
728
+ previousClaimant: Claimant,
729
+ newClaimant: Claimant,
730
+ stealableInfo: StealableInfo,
731
+ contestWindowEndsAt: Date
732
+ ): Promise<void> {
733
+ const event: IssueStolenEvent = {
734
+ id: `evt-${randomUUID()}`,
735
+ type: 'IssueStolen',
736
+ timestamp: new Date(),
737
+ issueId: claim.issueId,
738
+ claimId: claim.id,
739
+ payload: {
740
+ previousClaimant,
741
+ newClaimant,
742
+ stealableInfo,
743
+ contestWindowEndsAt,
744
+ },
745
+ };
746
+
747
+ await this.eventBus.emit(event);
748
+ }
749
+
750
+ /**
751
+ * Emit StealContested event
752
+ */
753
+ private async emitContestEvent(claim: IssueClaimWithStealing): Promise<void> {
754
+ const event: StealContestedEvent = {
755
+ id: `evt-${randomUUID()}`,
756
+ type: 'StealContested',
757
+ timestamp: new Date(),
758
+ issueId: claim.issueId,
759
+ claimId: claim.id,
760
+ payload: {
761
+ contestInfo: claim.contestInfo!,
762
+ claim,
763
+ },
764
+ };
765
+
766
+ await this.eventBus.emit(event);
767
+ }
768
+
769
+ /**
770
+ * Emit StealContestResolved event
771
+ */
772
+ private async emitContestResolvedEvent(
773
+ claim: IssueClaimWithStealing,
774
+ resolution: ContestResolution
775
+ ): Promise<void> {
776
+ const event: StealContestResolvedEvent = {
777
+ id: `evt-${randomUUID()}`,
778
+ type: 'StealContestResolved',
779
+ timestamp: new Date(),
780
+ issueId: claim.issueId,
781
+ claimId: claim.id,
782
+ payload: {
783
+ contestInfo: claim.contestInfo!,
784
+ resolution,
785
+ winnerClaim: claim,
786
+ },
787
+ };
788
+
789
+ await this.eventBus.emit(event);
790
+ }
791
+ }
792
+
793
+ // =============================================================================
794
+ // Factory Function
795
+ // =============================================================================
796
+
797
+ /**
798
+ * Create a new WorkStealingService with default event bus
799
+ */
800
+ export function createWorkStealingService(
801
+ repository: IIssueClaimRepository,
802
+ config?: Partial<WorkStealingConfig>,
803
+ eventBus?: IWorkStealingEventBus
804
+ ): WorkStealingService {
805
+ const bus = eventBus ?? new InMemoryWorkStealingEventBus();
806
+ return new WorkStealingService(repository, bus, config);
807
+ }