opencode-swarm-plugin 0.17.1 → 0.19.0
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/.beads/issues.jsonl +95 -85
- package/.github/workflows/ci.yml +5 -1
- package/README.md +48 -4
- package/dist/index.js +2652 -2080
- package/dist/plugin.js +2302 -1725
- package/package.json +1 -1
- package/src/agent-mail.ts +13 -0
- package/src/anti-patterns.test.ts +1167 -0
- package/src/anti-patterns.ts +29 -11
- package/src/learning.ts +106 -0
- package/src/pattern-maturity.ts +51 -13
- package/src/plugin.ts +15 -3
- package/src/rate-limiter.ts +48 -4
- package/src/schemas/bead.ts +35 -4
- package/src/schemas/evaluation.ts +18 -6
- package/src/schemas/index.ts +25 -2
- package/src/schemas/task.ts +49 -21
- package/src/streams/debug.ts +101 -3
- package/src/streams/index.ts +87 -1
- package/src/streams/migrations.ts +46 -4
- package/src/streams/projections.ts +15 -0
- package/src/streams/store.integration.test.ts +110 -0
- package/src/streams/store.ts +447 -193
- package/src/structured.test.ts +1046 -0
- package/src/structured.ts +74 -27
- package/src/swarm-decompose.ts +912 -0
- package/src/swarm-orchestrate.ts +1869 -0
- package/src/swarm-prompts.ts +756 -0
- package/src/swarm-strategies.ts +407 -0
- package/src/swarm.ts +23 -3639
- package/src/tool-availability.ts +29 -6
- package/test-bug-fixes.ts +86 -0
package/src/streams/debug.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Useful for debugging issues and understanding system behavior.
|
|
6
6
|
*/
|
|
7
7
|
import { getDatabase, getDatabaseStats } from "./index";
|
|
8
|
-
import { readEvents, getLatestSequence } from "./store";
|
|
8
|
+
import { readEvents, getLatestSequence, replayEventsBatched } from "./store";
|
|
9
9
|
import { getAgent, getActiveReservations, getMessage } from "./projections";
|
|
10
10
|
import type { AgentEvent } from "./events";
|
|
11
11
|
|
|
@@ -231,11 +231,27 @@ function getAgentFromEvent(event: AgentEvent): string {
|
|
|
231
231
|
|
|
232
232
|
/**
|
|
233
233
|
* Get recent events with filtering
|
|
234
|
+
*
|
|
235
|
+
* For large event logs (>100k events), consider using batchSize option
|
|
236
|
+
* to paginate through results instead of loading all events.
|
|
234
237
|
*/
|
|
235
238
|
export async function debugEvents(
|
|
236
|
-
options: DebugEventsOptions,
|
|
239
|
+
options: DebugEventsOptions & { batchSize?: number },
|
|
237
240
|
): Promise<DebugEventsResult> {
|
|
238
|
-
const {
|
|
241
|
+
const {
|
|
242
|
+
projectPath,
|
|
243
|
+
types,
|
|
244
|
+
agentName,
|
|
245
|
+
limit = 50,
|
|
246
|
+
since,
|
|
247
|
+
until,
|
|
248
|
+
batchSize,
|
|
249
|
+
} = options;
|
|
250
|
+
|
|
251
|
+
// If batchSize is specified, use pagination to avoid OOM
|
|
252
|
+
if (batchSize && batchSize > 0) {
|
|
253
|
+
return await debugEventsPaginated({ ...options, batchSize });
|
|
254
|
+
}
|
|
239
255
|
|
|
240
256
|
// Get all events first (we'll filter in memory for agent name)
|
|
241
257
|
const allEvents = await readEvents(
|
|
@@ -284,6 +300,88 @@ export async function debugEvents(
|
|
|
284
300
|
};
|
|
285
301
|
}
|
|
286
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Get events using pagination to avoid OOM on large logs
|
|
305
|
+
*/
|
|
306
|
+
async function debugEventsPaginated(
|
|
307
|
+
options: DebugEventsOptions & { batchSize: number },
|
|
308
|
+
): Promise<DebugEventsResult> {
|
|
309
|
+
const {
|
|
310
|
+
projectPath,
|
|
311
|
+
types,
|
|
312
|
+
agentName,
|
|
313
|
+
limit = 50,
|
|
314
|
+
since,
|
|
315
|
+
until,
|
|
316
|
+
batchSize,
|
|
317
|
+
} = options;
|
|
318
|
+
|
|
319
|
+
const allEvents: Array<AgentEvent & { id: number; sequence: number }> = [];
|
|
320
|
+
let offset = 0;
|
|
321
|
+
let hasMore = true;
|
|
322
|
+
|
|
323
|
+
// Fetch in batches until we have enough events or run out
|
|
324
|
+
while (hasMore && allEvents.length < limit) {
|
|
325
|
+
const batch = await readEvents(
|
|
326
|
+
{
|
|
327
|
+
projectKey: projectPath,
|
|
328
|
+
types,
|
|
329
|
+
since,
|
|
330
|
+
until,
|
|
331
|
+
limit: batchSize,
|
|
332
|
+
offset,
|
|
333
|
+
},
|
|
334
|
+
projectPath,
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
if (batch.length === 0) {
|
|
338
|
+
hasMore = false;
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Filter by agent name if specified
|
|
343
|
+
const filtered = agentName
|
|
344
|
+
? batch.filter((e) => {
|
|
345
|
+
if ("agent_name" in e && e.agent_name === agentName) return true;
|
|
346
|
+
if ("from_agent" in e && e.from_agent === agentName) return true;
|
|
347
|
+
if ("to_agents" in e && e.to_agents?.includes(agentName)) return true;
|
|
348
|
+
return false;
|
|
349
|
+
})
|
|
350
|
+
: batch;
|
|
351
|
+
|
|
352
|
+
allEvents.push(...filtered);
|
|
353
|
+
offset += batchSize;
|
|
354
|
+
|
|
355
|
+
console.log(
|
|
356
|
+
`[SwarmMail] Fetched ${allEvents.length} events (batch size: ${batchSize})`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Sort by sequence descending (most recent first)
|
|
361
|
+
allEvents.sort((a, b) => b.sequence - a.sequence);
|
|
362
|
+
|
|
363
|
+
// Apply limit
|
|
364
|
+
const limitedEvents = allEvents.slice(0, limit);
|
|
365
|
+
|
|
366
|
+
// Format for output
|
|
367
|
+
const events: DebugEventResult[] = limitedEvents.map((e) => {
|
|
368
|
+
const { id, sequence, type, timestamp, project_key, ...rest } = e;
|
|
369
|
+
return {
|
|
370
|
+
id,
|
|
371
|
+
sequence,
|
|
372
|
+
type,
|
|
373
|
+
timestamp,
|
|
374
|
+
timestamp_human: formatTimestamp(timestamp),
|
|
375
|
+
...rest,
|
|
376
|
+
};
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
events,
|
|
381
|
+
total: allEvents.length,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
287
385
|
/**
|
|
288
386
|
* Get detailed agent information
|
|
289
387
|
*/
|
package/src/streams/index.ts
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* SwarmMail Event Store - PGLite-based event sourcing
|
|
3
|
+
*
|
|
4
|
+
* ## Thread Safety
|
|
5
|
+
*
|
|
6
|
+
* PGLite runs in-process as a single-threaded SQLite-compatible database.
|
|
7
|
+
* While Node.js is single-threaded, async operations can interleave.
|
|
8
|
+
*
|
|
9
|
+
* **Concurrency Model:**
|
|
10
|
+
* - Single PGLite instance per project (singleton pattern via LRU cache)
|
|
11
|
+
* - Transactions provide isolation for multi-statement operations
|
|
12
|
+
* - appendEvents uses BEGIN/COMMIT for atomic event batches
|
|
13
|
+
* - Concurrent reads are safe (no locks needed)
|
|
14
|
+
* - Concurrent writes are serialized by PGLite internally
|
|
15
|
+
*
|
|
16
|
+
* **Race Condition Mitigations:**
|
|
17
|
+
* - File reservations use INSERT with conflict detection
|
|
18
|
+
* - Sequence numbers are auto-incremented by database
|
|
19
|
+
* - Materialized views updated within same transaction as events
|
|
20
|
+
* - Pending instance promises prevent duplicate initialization
|
|
21
|
+
*
|
|
22
|
+
* **Known Limitations:**
|
|
23
|
+
* - No distributed locking (single-process only)
|
|
24
|
+
* - Large transactions may block other operations
|
|
25
|
+
* - No connection pooling (embedded database)
|
|
26
|
+
*
|
|
27
|
+
* ## Database Setup
|
|
3
28
|
*
|
|
4
29
|
* Embedded PostgreSQL database for event sourcing.
|
|
5
30
|
* No external server required - runs in-process.
|
|
@@ -12,6 +37,67 @@ import { existsSync, mkdirSync, appendFileSync } from "node:fs";
|
|
|
12
37
|
import { join } from "node:path";
|
|
13
38
|
import { homedir } from "node:os";
|
|
14
39
|
|
|
40
|
+
// ============================================================================
|
|
41
|
+
// Query Timeout Wrapper
|
|
42
|
+
// ============================================================================
|
|
43
|
+
|
|
44
|
+
const DEFAULT_QUERY_TIMEOUT_MS = 30000; // 30 seconds
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Wrap a promise with a timeout
|
|
48
|
+
*
|
|
49
|
+
* @param promise - The promise to wrap
|
|
50
|
+
* @param ms - Timeout in milliseconds
|
|
51
|
+
* @param operation - Operation name for error message
|
|
52
|
+
* @returns The result of the promise
|
|
53
|
+
* @throws Error if timeout is reached
|
|
54
|
+
*/
|
|
55
|
+
export async function withTimeout<T>(
|
|
56
|
+
promise: Promise<T>,
|
|
57
|
+
ms: number,
|
|
58
|
+
operation: string,
|
|
59
|
+
): Promise<T> {
|
|
60
|
+
const timeout = new Promise<never>((_, reject) =>
|
|
61
|
+
setTimeout(
|
|
62
|
+
() => reject(new Error(`${operation} timed out after ${ms}ms`)),
|
|
63
|
+
ms,
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
return Promise.race([promise, timeout]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ============================================================================
|
|
70
|
+
// Performance Monitoring
|
|
71
|
+
// ============================================================================
|
|
72
|
+
|
|
73
|
+
/** Threshold for slow query warnings in milliseconds */
|
|
74
|
+
const SLOW_QUERY_THRESHOLD_MS = 100;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Execute a database operation with timing instrumentation.
|
|
78
|
+
* Logs a warning if the operation exceeds SLOW_QUERY_THRESHOLD_MS.
|
|
79
|
+
*
|
|
80
|
+
* @param operation - Name of the operation for logging
|
|
81
|
+
* @param fn - Async function to execute
|
|
82
|
+
* @returns Result of the function
|
|
83
|
+
*/
|
|
84
|
+
export async function withTiming<T>(
|
|
85
|
+
operation: string,
|
|
86
|
+
fn: () => Promise<T>,
|
|
87
|
+
): Promise<T> {
|
|
88
|
+
const start = performance.now();
|
|
89
|
+
try {
|
|
90
|
+
return await fn();
|
|
91
|
+
} finally {
|
|
92
|
+
const duration = performance.now() - start;
|
|
93
|
+
if (duration > SLOW_QUERY_THRESHOLD_MS) {
|
|
94
|
+
console.warn(
|
|
95
|
+
`[SwarmMail] Slow operation: ${operation} took ${duration.toFixed(1)}ms`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
15
101
|
// ============================================================================
|
|
16
102
|
// Debug Logging
|
|
17
103
|
// ============================================================================
|
|
@@ -1,9 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Schema Migration System
|
|
2
|
+
* Schema Migration System
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Handles database schema evolution for the PGLite event store.
|
|
5
|
+
*
|
|
6
|
+
* ## How It Works
|
|
7
|
+
*
|
|
8
|
+
* 1. Each migration has a unique version number (incrementing integer)
|
|
9
|
+
* 2. On startup, `runMigrations()` checks current schema version
|
|
10
|
+
* 3. Migrations are applied in order until schema is current
|
|
11
|
+
* 4. Version is stored in `schema_version` table
|
|
12
|
+
*
|
|
13
|
+
* ## Adding a New Migration
|
|
14
|
+
*
|
|
15
|
+
* ```typescript
|
|
16
|
+
* // In migrations.ts
|
|
17
|
+
* export const migrations: Migration[] = [
|
|
18
|
+
* // ... existing migrations
|
|
19
|
+
* {
|
|
20
|
+
* version: 3,
|
|
21
|
+
* description: "add_new_column",
|
|
22
|
+
* up: `ALTER TABLE events ADD COLUMN new_col TEXT`,
|
|
23
|
+
* down: `ALTER TABLE events DROP COLUMN new_col`,
|
|
24
|
+
* },
|
|
25
|
+
* ];
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* ## Rollback
|
|
29
|
+
*
|
|
30
|
+
* Rollback is supported via `rollbackTo(db, targetVersion)`.
|
|
31
|
+
* Note: Some migrations may not be fully reversible (data loss).
|
|
32
|
+
*
|
|
33
|
+
* ## Best Practices
|
|
34
|
+
*
|
|
35
|
+
* - Always test migrations on a copy of production data
|
|
36
|
+
* - Keep migrations small and focused
|
|
37
|
+
* - Include both `up` and `down` SQL
|
|
38
|
+
* - Use transactions for multi-statement migrations
|
|
39
|
+
* - Document any data transformations
|
|
40
|
+
*
|
|
41
|
+
* @module migrations
|
|
7
42
|
*/
|
|
8
43
|
import type { PGlite } from "@electric-sql/pglite";
|
|
9
44
|
|
|
@@ -11,10 +46,17 @@ import type { PGlite } from "@electric-sql/pglite";
|
|
|
11
46
|
// Types
|
|
12
47
|
// ============================================================================
|
|
13
48
|
|
|
49
|
+
/**
|
|
50
|
+
* A database migration definition.
|
|
51
|
+
*/
|
|
14
52
|
export interface Migration {
|
|
53
|
+
/** Unique version number (must be sequential) */
|
|
15
54
|
version: number;
|
|
55
|
+
/** Human-readable migration description */
|
|
16
56
|
description: string;
|
|
57
|
+
/** SQL to apply the migration */
|
|
17
58
|
up: string;
|
|
59
|
+
/** SQL to rollback the migration (best effort) */
|
|
18
60
|
down: string;
|
|
19
61
|
}
|
|
20
62
|
|
|
@@ -275,6 +275,13 @@ export async function checkConflicts(
|
|
|
275
275
|
// Check each requested path against the reservation pattern
|
|
276
276
|
for (const path of paths) {
|
|
277
277
|
if (pathMatches(path, reservation.path_pattern)) {
|
|
278
|
+
console.warn("[SwarmMail] Conflict detected", {
|
|
279
|
+
path,
|
|
280
|
+
holder: reservation.agent_name,
|
|
281
|
+
pattern: reservation.path_pattern,
|
|
282
|
+
requestedBy: agentName,
|
|
283
|
+
});
|
|
284
|
+
|
|
278
285
|
conflicts.push({
|
|
279
286
|
path,
|
|
280
287
|
holder: reservation.agent_name,
|
|
@@ -285,6 +292,14 @@ export async function checkConflicts(
|
|
|
285
292
|
}
|
|
286
293
|
}
|
|
287
294
|
|
|
295
|
+
if (conflicts.length > 0) {
|
|
296
|
+
console.warn("[SwarmMail] Total conflicts detected", {
|
|
297
|
+
count: conflicts.length,
|
|
298
|
+
requestedBy: agentName,
|
|
299
|
+
paths,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
288
303
|
return conflicts;
|
|
289
304
|
}
|
|
290
305
|
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
readEvents,
|
|
18
18
|
getLatestSequence,
|
|
19
19
|
replayEvents,
|
|
20
|
+
replayEventsBatched,
|
|
20
21
|
registerAgent,
|
|
21
22
|
sendMessage,
|
|
22
23
|
reserveFiles,
|
|
@@ -343,6 +344,115 @@ describe("Event Store", () => {
|
|
|
343
344
|
});
|
|
344
345
|
});
|
|
345
346
|
|
|
347
|
+
describe("replayEventsBatched", () => {
|
|
348
|
+
it("should replay events in batches with progress tracking", async () => {
|
|
349
|
+
// Create 50 events
|
|
350
|
+
for (let i = 0; i < 50; i++) {
|
|
351
|
+
await registerAgent(
|
|
352
|
+
"test-project",
|
|
353
|
+
`Agent${i}`,
|
|
354
|
+
{ taskDescription: `Agent ${i}` },
|
|
355
|
+
TEST_PROJECT_PATH,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Manually corrupt the views
|
|
360
|
+
const db = await getDatabase(TEST_PROJECT_PATH);
|
|
361
|
+
await db.query("DELETE FROM agents WHERE project_key = 'test-project'");
|
|
362
|
+
|
|
363
|
+
// Verify views are empty
|
|
364
|
+
const empty = await db.query<{ count: string }>(
|
|
365
|
+
"SELECT COUNT(*) as count FROM agents WHERE project_key = 'test-project'",
|
|
366
|
+
);
|
|
367
|
+
expect(parseInt(empty.rows[0]?.count ?? "0")).toBe(0);
|
|
368
|
+
|
|
369
|
+
// Track progress
|
|
370
|
+
const progressUpdates: Array<{
|
|
371
|
+
processed: number;
|
|
372
|
+
total: number;
|
|
373
|
+
percent: number;
|
|
374
|
+
}> = [];
|
|
375
|
+
|
|
376
|
+
// Replay in batches of 10
|
|
377
|
+
const result = await replayEventsBatched(
|
|
378
|
+
"test-project",
|
|
379
|
+
async (_events, progress) => {
|
|
380
|
+
progressUpdates.push(progress);
|
|
381
|
+
},
|
|
382
|
+
{ batchSize: 10, clearViews: false },
|
|
383
|
+
TEST_PROJECT_PATH,
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
// Verify all events replayed
|
|
387
|
+
expect(result.eventsReplayed).toBe(50);
|
|
388
|
+
|
|
389
|
+
// Verify progress updates
|
|
390
|
+
expect(progressUpdates.length).toBe(5); // 50 events / 10 per batch = 5 batches
|
|
391
|
+
expect(progressUpdates[0]).toMatchObject({
|
|
392
|
+
processed: 10,
|
|
393
|
+
total: 50,
|
|
394
|
+
percent: 20,
|
|
395
|
+
});
|
|
396
|
+
expect(progressUpdates[4]).toMatchObject({
|
|
397
|
+
processed: 50,
|
|
398
|
+
total: 50,
|
|
399
|
+
percent: 100,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// Verify views are restored
|
|
403
|
+
const restored = await db.query<{ count: string }>(
|
|
404
|
+
"SELECT COUNT(*) as count FROM agents WHERE project_key = 'test-project'",
|
|
405
|
+
);
|
|
406
|
+
expect(parseInt(restored.rows[0]?.count ?? "0")).toBe(50);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("should handle zero events gracefully", async () => {
|
|
410
|
+
const progressUpdates: Array<{
|
|
411
|
+
processed: number;
|
|
412
|
+
total: number;
|
|
413
|
+
percent: number;
|
|
414
|
+
}> = [];
|
|
415
|
+
|
|
416
|
+
const result = await replayEventsBatched(
|
|
417
|
+
"test-project",
|
|
418
|
+
async (_events, progress) => {
|
|
419
|
+
progressUpdates.push(progress);
|
|
420
|
+
},
|
|
421
|
+
{ batchSize: 10 },
|
|
422
|
+
TEST_PROJECT_PATH,
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
expect(result.eventsReplayed).toBe(0);
|
|
426
|
+
expect(progressUpdates.length).toBe(0);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("should use custom batch size", async () => {
|
|
430
|
+
// Create 25 events
|
|
431
|
+
for (let i = 0; i < 25; i++) {
|
|
432
|
+
await registerAgent("test-project", `Agent${i}`, {}, TEST_PROJECT_PATH);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const progressUpdates: Array<{
|
|
436
|
+
processed: number;
|
|
437
|
+
total: number;
|
|
438
|
+
percent: number;
|
|
439
|
+
}> = [];
|
|
440
|
+
|
|
441
|
+
// Replay with batch size of 5
|
|
442
|
+
await replayEventsBatched(
|
|
443
|
+
"test-project",
|
|
444
|
+
async (_events, progress) => {
|
|
445
|
+
progressUpdates.push(progress);
|
|
446
|
+
},
|
|
447
|
+
{ batchSize: 5, clearViews: false },
|
|
448
|
+
TEST_PROJECT_PATH,
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
// Should have 5 batches (25 events / 5 per batch)
|
|
452
|
+
expect(progressUpdates.length).toBe(5);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
346
456
|
describe("getDatabaseStats", () => {
|
|
347
457
|
it("should return correct counts", async () => {
|
|
348
458
|
await registerAgent("test-project", "Agent1", {}, TEST_PROJECT_PATH);
|