@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.
- package/README.md +261 -0
- package/package.json +84 -0
- package/src/api/cli-commands.ts +1459 -0
- package/src/api/cli-types.ts +154 -0
- package/src/api/index.ts +24 -0
- package/src/api/mcp-tools.ts +1977 -0
- package/src/application/claim-service.ts +753 -0
- package/src/application/index.ts +46 -0
- package/src/application/load-balancer.ts +840 -0
- package/src/application/work-stealing-service.ts +807 -0
- package/src/domain/events.ts +779 -0
- package/src/domain/index.ts +214 -0
- package/src/domain/repositories.ts +239 -0
- package/src/domain/rules.ts +526 -0
- package/src/domain/types.ts +826 -0
- package/src/index.ts +79 -0
- package/src/infrastructure/claim-repository.ts +358 -0
- package/src/infrastructure/event-store.ts +297 -0
- package/src/infrastructure/index.ts +21 -0
|
@@ -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
|
+
}
|