opencode-swarm-plugin 0.18.0 → 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 +71 -61
- package/.github/workflows/ci.yml +5 -1
- package/README.md +48 -4
- package/dist/index.js +6643 -6326
- package/dist/plugin.js +2726 -2404
- 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/pattern-maturity.ts +51 -13
- package/src/plugin.ts +15 -3
- 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 +58 -1
- package/src/streams/migrations.ts +46 -4
- package/src/streams/store.integration.test.ts +110 -0
- package/src/streams/store.ts +311 -126
- 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 -3876
- package/src/tool-availability.ts +29 -6
- package/test-bug-fixes.ts +86 -0
package/src/streams/store.ts
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
* - append(): Add events to the log
|
|
6
6
|
* - read(): Read events with filters
|
|
7
7
|
* - replay(): Rebuild state from events
|
|
8
|
+
* - replayBatched(): Rebuild state with pagination (for large logs)
|
|
8
9
|
*
|
|
9
10
|
* All state changes go through events. Projections compute current state.
|
|
10
11
|
*/
|
|
11
|
-
import { getDatabase } from "./index";
|
|
12
|
+
import { getDatabase, withTiming } from "./index";
|
|
12
13
|
import {
|
|
13
14
|
type AgentEvent,
|
|
14
15
|
createEvent,
|
|
@@ -17,6 +18,40 @@ import {
|
|
|
17
18
|
type FileReservedEvent,
|
|
18
19
|
} from "./events";
|
|
19
20
|
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Timestamp Parsing
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Maximum safe timestamp before integer overflow (approximately year 2286)
|
|
27
|
+
* PostgreSQL BIGINT can exceed JavaScript's MAX_SAFE_INTEGER (2^53-1)
|
|
28
|
+
*/
|
|
29
|
+
const TIMESTAMP_SAFE_UNTIL = new Date("2286-01-01").getTime();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse timestamp from database row.
|
|
33
|
+
*
|
|
34
|
+
* NOTE: Timestamps are stored as BIGINT but parsed as JavaScript number.
|
|
35
|
+
* This is safe for dates before year 2286 (MAX_SAFE_INTEGER = 9007199254740991).
|
|
36
|
+
* For timestamps beyond this range, use BigInt parsing.
|
|
37
|
+
*
|
|
38
|
+
* @param timestamp String representation of Unix timestamp in milliseconds
|
|
39
|
+
* @returns JavaScript number (safe for dates before 2286)
|
|
40
|
+
* @throws Error if timestamp is not a valid number
|
|
41
|
+
*/
|
|
42
|
+
function parseTimestamp(timestamp: string): number {
|
|
43
|
+
const ts = parseInt(timestamp, 10);
|
|
44
|
+
if (Number.isNaN(ts)) {
|
|
45
|
+
throw new Error(`[SwarmMail] Invalid timestamp: ${timestamp}`);
|
|
46
|
+
}
|
|
47
|
+
if (ts > Number.MAX_SAFE_INTEGER) {
|
|
48
|
+
console.warn(
|
|
49
|
+
`[SwarmMail] Timestamp ${timestamp} exceeds MAX_SAFE_INTEGER (year 2286+), precision may be lost`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return ts;
|
|
53
|
+
}
|
|
54
|
+
|
|
20
55
|
// ============================================================================
|
|
21
56
|
// Event Store Operations
|
|
22
57
|
// ============================================================================
|
|
@@ -76,43 +111,59 @@ export async function appendEvents(
|
|
|
76
111
|
events: AgentEvent[],
|
|
77
112
|
projectPath?: string,
|
|
78
113
|
): Promise<Array<AgentEvent & { id: number; sequence: number }>> {
|
|
79
|
-
|
|
80
|
-
|
|
114
|
+
return withTiming("appendEvents", async () => {
|
|
115
|
+
const db = await getDatabase(projectPath);
|
|
116
|
+
const results: Array<AgentEvent & { id: number; sequence: number }> = [];
|
|
81
117
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
118
|
+
await db.exec("BEGIN");
|
|
119
|
+
try {
|
|
120
|
+
for (const event of events) {
|
|
121
|
+
const { type, project_key, timestamp, ...rest } = event;
|
|
122
|
+
|
|
123
|
+
const result = await db.query<{ id: number; sequence: number }>(
|
|
124
|
+
`INSERT INTO events (type, project_key, timestamp, data)
|
|
125
|
+
VALUES ($1, $2, $3, $4)
|
|
126
|
+
RETURNING id, sequence`,
|
|
127
|
+
[type, project_key, timestamp, JSON.stringify(rest)],
|
|
128
|
+
);
|
|
86
129
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
130
|
+
const row = result.rows[0];
|
|
131
|
+
if (!row) {
|
|
132
|
+
throw new Error("Failed to insert event - no row returned");
|
|
133
|
+
}
|
|
134
|
+
const { id, sequence } = row;
|
|
135
|
+
const enrichedEvent = { ...event, id, sequence };
|
|
93
136
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
137
|
+
await updateMaterializedViews(db, enrichedEvent);
|
|
138
|
+
results.push(enrichedEvent);
|
|
139
|
+
}
|
|
140
|
+
await db.exec("COMMIT");
|
|
141
|
+
} catch (e) {
|
|
142
|
+
// FIX: Propagate rollback failures to prevent silent data corruption
|
|
143
|
+
let rollbackError: unknown = null;
|
|
144
|
+
try {
|
|
145
|
+
await db.exec("ROLLBACK");
|
|
146
|
+
} catch (rbErr) {
|
|
147
|
+
rollbackError = rbErr;
|
|
148
|
+
console.error("[SwarmMail] ROLLBACK failed:", rbErr);
|
|
97
149
|
}
|
|
98
|
-
const { id, sequence } = row;
|
|
99
|
-
const enrichedEvent = { ...event, id, sequence };
|
|
100
150
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
151
|
+
if (rollbackError) {
|
|
152
|
+
// Throw composite error so caller knows both failures
|
|
153
|
+
const compositeError = new Error(
|
|
154
|
+
`Transaction failed: ${e instanceof Error ? e.message : String(e)}. ` +
|
|
155
|
+
`ROLLBACK also failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}. ` +
|
|
156
|
+
`Database may be in inconsistent state.`,
|
|
157
|
+
);
|
|
158
|
+
(compositeError as any).originalError = e;
|
|
159
|
+
(compositeError as any).rollbackError = rollbackError;
|
|
160
|
+
throw compositeError;
|
|
161
|
+
}
|
|
162
|
+
throw e;
|
|
111
163
|
}
|
|
112
|
-
throw e;
|
|
113
|
-
}
|
|
114
164
|
|
|
115
|
-
|
|
165
|
+
return results;
|
|
166
|
+
});
|
|
116
167
|
}
|
|
117
168
|
|
|
118
169
|
/**
|
|
@@ -130,76 +181,79 @@ export async function readEvents(
|
|
|
130
181
|
} = {},
|
|
131
182
|
projectPath?: string,
|
|
132
183
|
): Promise<Array<AgentEvent & { id: number; sequence: number }>> {
|
|
133
|
-
|
|
184
|
+
return withTiming("readEvents", async () => {
|
|
185
|
+
const db = await getDatabase(projectPath);
|
|
134
186
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
187
|
+
const conditions: string[] = [];
|
|
188
|
+
const params: unknown[] = [];
|
|
189
|
+
let paramIndex = 1;
|
|
138
190
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
191
|
+
if (options.projectKey) {
|
|
192
|
+
conditions.push(`project_key = $${paramIndex++}`);
|
|
193
|
+
params.push(options.projectKey);
|
|
194
|
+
}
|
|
143
195
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
196
|
+
if (options.types && options.types.length > 0) {
|
|
197
|
+
conditions.push(`type = ANY($${paramIndex++})`);
|
|
198
|
+
params.push(options.types);
|
|
199
|
+
}
|
|
148
200
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
201
|
+
if (options.since !== undefined) {
|
|
202
|
+
conditions.push(`timestamp >= $${paramIndex++}`);
|
|
203
|
+
params.push(options.since);
|
|
204
|
+
}
|
|
153
205
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
206
|
+
if (options.until !== undefined) {
|
|
207
|
+
conditions.push(`timestamp <= $${paramIndex++}`);
|
|
208
|
+
params.push(options.until);
|
|
209
|
+
}
|
|
158
210
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
211
|
+
if (options.afterSequence !== undefined) {
|
|
212
|
+
conditions.push(`sequence > $${paramIndex++}`);
|
|
213
|
+
params.push(options.afterSequence);
|
|
214
|
+
}
|
|
163
215
|
|
|
164
|
-
|
|
165
|
-
|
|
216
|
+
const whereClause =
|
|
217
|
+
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
166
218
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
219
|
+
let query = `
|
|
220
|
+
SELECT id, type, project_key, timestamp, sequence, data
|
|
221
|
+
FROM events
|
|
222
|
+
${whereClause}
|
|
223
|
+
ORDER BY sequence ASC
|
|
224
|
+
`;
|
|
173
225
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
226
|
+
if (options.limit) {
|
|
227
|
+
query += ` LIMIT $${paramIndex++}`;
|
|
228
|
+
params.push(options.limit);
|
|
229
|
+
}
|
|
178
230
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
231
|
+
if (options.offset) {
|
|
232
|
+
query += ` OFFSET $${paramIndex++}`;
|
|
233
|
+
params.push(options.offset);
|
|
234
|
+
}
|
|
183
235
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
236
|
+
const result = await db.query<{
|
|
237
|
+
id: number;
|
|
238
|
+
type: string;
|
|
239
|
+
project_key: string;
|
|
240
|
+
timestamp: string;
|
|
241
|
+
sequence: number;
|
|
242
|
+
data: string;
|
|
243
|
+
}>(query, params);
|
|
244
|
+
|
|
245
|
+
return result.rows.map((row) => {
|
|
246
|
+
const data =
|
|
247
|
+
typeof row.data === "string" ? JSON.parse(row.data) : row.data;
|
|
248
|
+
return {
|
|
249
|
+
id: row.id,
|
|
250
|
+
type: row.type as AgentEvent["type"],
|
|
251
|
+
project_key: row.project_key,
|
|
252
|
+
timestamp: parseTimestamp(row.timestamp as string),
|
|
253
|
+
sequence: row.sequence,
|
|
254
|
+
...data,
|
|
255
|
+
} as AgentEvent & { id: number; sequence: number };
|
|
256
|
+
});
|
|
203
257
|
});
|
|
204
258
|
}
|
|
205
259
|
|
|
@@ -238,56 +292,174 @@ export async function replayEvents(
|
|
|
238
292
|
} = {},
|
|
239
293
|
projectPath?: string,
|
|
240
294
|
): Promise<{ eventsReplayed: number; duration: number }> {
|
|
241
|
-
|
|
242
|
-
|
|
295
|
+
return withTiming("replayEvents", async () => {
|
|
296
|
+
const startTime = Date.now();
|
|
297
|
+
const db = await getDatabase(projectPath);
|
|
298
|
+
|
|
299
|
+
// Optionally clear materialized views
|
|
300
|
+
if (options.clearViews) {
|
|
301
|
+
if (options.projectKey) {
|
|
302
|
+
// Use parameterized queries to prevent SQL injection
|
|
303
|
+
await db.query(
|
|
304
|
+
`DELETE FROM message_recipients WHERE message_id IN (
|
|
305
|
+
SELECT id FROM messages WHERE project_key = $1
|
|
306
|
+
)`,
|
|
307
|
+
[options.projectKey],
|
|
308
|
+
);
|
|
309
|
+
await db.query(`DELETE FROM messages WHERE project_key = $1`, [
|
|
310
|
+
options.projectKey,
|
|
311
|
+
]);
|
|
312
|
+
await db.query(`DELETE FROM reservations WHERE project_key = $1`, [
|
|
313
|
+
options.projectKey,
|
|
314
|
+
]);
|
|
315
|
+
await db.query(`DELETE FROM agents WHERE project_key = $1`, [
|
|
316
|
+
options.projectKey,
|
|
317
|
+
]);
|
|
318
|
+
} else {
|
|
319
|
+
await db.exec(`
|
|
320
|
+
DELETE FROM message_recipients;
|
|
321
|
+
DELETE FROM messages;
|
|
322
|
+
DELETE FROM reservations;
|
|
323
|
+
DELETE FROM agents;
|
|
324
|
+
`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
243
327
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
328
|
+
// Read all events
|
|
329
|
+
const events = await readEvents(
|
|
330
|
+
{
|
|
331
|
+
projectKey: options.projectKey,
|
|
332
|
+
afterSequence: options.fromSequence,
|
|
333
|
+
},
|
|
334
|
+
projectPath,
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
// Replay each event
|
|
338
|
+
for (const event of events) {
|
|
339
|
+
await updateMaterializedViews(db, event);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
eventsReplayed: events.length,
|
|
344
|
+
duration: Date.now() - startTime,
|
|
345
|
+
};
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Replay events in batches to avoid OOM
|
|
351
|
+
*
|
|
352
|
+
* For large event logs (>100k events), use this instead of replayEvents()
|
|
353
|
+
* to keep memory usage constant.
|
|
354
|
+
*
|
|
355
|
+
* Example:
|
|
356
|
+
* ```typescript
|
|
357
|
+
* const result = await replayEventsBatched(
|
|
358
|
+
* "my-project",
|
|
359
|
+
* async (events, progress) => {
|
|
360
|
+
* console.log(`Replayed ${progress.processed}/${progress.total} (${progress.percent}%)`);
|
|
361
|
+
* },
|
|
362
|
+
* { batchSize: 1000, clearViews: true },
|
|
363
|
+
* "/path/to/project"
|
|
364
|
+
* );
|
|
365
|
+
* console.log(`Replayed ${result.eventsReplayed} events in ${result.duration}ms`);
|
|
366
|
+
* ```
|
|
367
|
+
*
|
|
368
|
+
* @param projectKey Project key to filter events
|
|
369
|
+
* @param onBatch Callback invoked for each batch with progress
|
|
370
|
+
* @param options Configuration options
|
|
371
|
+
* @param options.batchSize Number of events per batch (default 1000)
|
|
372
|
+
* @param options.fromSequence Start from this sequence number (default 0)
|
|
373
|
+
* @param options.clearViews Clear materialized views before replay (default false)
|
|
374
|
+
* @param projectPath Path to project database
|
|
375
|
+
*/
|
|
376
|
+
export async function replayEventsBatched(
|
|
377
|
+
projectKey: string,
|
|
378
|
+
onBatch: (
|
|
379
|
+
events: Array<AgentEvent & { id: number; sequence: number }>,
|
|
380
|
+
progress: { processed: number; total: number; percent: number },
|
|
381
|
+
) => Promise<void>,
|
|
382
|
+
options: {
|
|
383
|
+
batchSize?: number;
|
|
384
|
+
fromSequence?: number;
|
|
385
|
+
clearViews?: boolean;
|
|
386
|
+
} = {},
|
|
387
|
+
projectPath?: string,
|
|
388
|
+
): Promise<{ eventsReplayed: number; duration: number }> {
|
|
389
|
+
return withTiming("replayEventsBatched", async () => {
|
|
390
|
+
const startTime = Date.now();
|
|
391
|
+
const batchSize = options.batchSize ?? 1000;
|
|
392
|
+
const fromSequence = options.fromSequence ?? 0;
|
|
393
|
+
const db = await getDatabase(projectPath);
|
|
394
|
+
|
|
395
|
+
// Optionally clear materialized views
|
|
396
|
+
if (options.clearViews) {
|
|
248
397
|
await db.query(
|
|
249
398
|
`DELETE FROM message_recipients WHERE message_id IN (
|
|
250
399
|
SELECT id FROM messages WHERE project_key = $1
|
|
251
400
|
)`,
|
|
252
|
-
[
|
|
401
|
+
[projectKey],
|
|
253
402
|
);
|
|
254
403
|
await db.query(`DELETE FROM messages WHERE project_key = $1`, [
|
|
255
|
-
|
|
404
|
+
projectKey,
|
|
256
405
|
]);
|
|
257
406
|
await db.query(`DELETE FROM reservations WHERE project_key = $1`, [
|
|
258
|
-
|
|
259
|
-
]);
|
|
260
|
-
await db.query(`DELETE FROM agents WHERE project_key = $1`, [
|
|
261
|
-
options.projectKey,
|
|
407
|
+
projectKey,
|
|
262
408
|
]);
|
|
263
|
-
|
|
264
|
-
await db.exec(`
|
|
265
|
-
DELETE FROM message_recipients;
|
|
266
|
-
DELETE FROM messages;
|
|
267
|
-
DELETE FROM reservations;
|
|
268
|
-
DELETE FROM agents;
|
|
269
|
-
`);
|
|
409
|
+
await db.query(`DELETE FROM agents WHERE project_key = $1`, [projectKey]);
|
|
270
410
|
}
|
|
271
|
-
}
|
|
272
411
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
projectKey
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
projectPath,
|
|
280
|
-
);
|
|
412
|
+
// Get total count first
|
|
413
|
+
const countResult = await db.query<{ count: string }>(
|
|
414
|
+
`SELECT COUNT(*) as count FROM events WHERE project_key = $1 AND sequence > $2`,
|
|
415
|
+
[projectKey, fromSequence],
|
|
416
|
+
);
|
|
417
|
+
const total = parseInt(countResult.rows[0]?.count ?? "0");
|
|
281
418
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
419
|
+
if (total === 0) {
|
|
420
|
+
return { eventsReplayed: 0, duration: Date.now() - startTime };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
let processed = 0;
|
|
424
|
+
let offset = 0;
|
|
425
|
+
|
|
426
|
+
while (processed < total) {
|
|
427
|
+
// Fetch batch
|
|
428
|
+
const events = await readEvents(
|
|
429
|
+
{
|
|
430
|
+
projectKey,
|
|
431
|
+
afterSequence: fromSequence,
|
|
432
|
+
limit: batchSize,
|
|
433
|
+
offset,
|
|
434
|
+
},
|
|
435
|
+
projectPath,
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
if (events.length === 0) break;
|
|
439
|
+
|
|
440
|
+
// Update materialized views for this batch
|
|
441
|
+
for (const event of events) {
|
|
442
|
+
await updateMaterializedViews(db, event);
|
|
443
|
+
}
|
|
286
444
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
445
|
+
processed += events.length;
|
|
446
|
+
const percent = Math.round((processed / total) * 100);
|
|
447
|
+
|
|
448
|
+
// Report progress
|
|
449
|
+
await onBatch(events, { processed, total, percent });
|
|
450
|
+
|
|
451
|
+
console.log(
|
|
452
|
+
`[SwarmMail] Replaying events: ${processed}/${total} (${percent}%)`,
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
offset += batchSize;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
eventsReplayed: processed,
|
|
460
|
+
duration: Date.now() - startTime,
|
|
461
|
+
};
|
|
462
|
+
});
|
|
291
463
|
}
|
|
292
464
|
|
|
293
465
|
// ============================================================================
|
|
@@ -480,6 +652,19 @@ async function handleFileReserved(
|
|
|
480
652
|
event.expires_at, // $N+6
|
|
481
653
|
];
|
|
482
654
|
|
|
655
|
+
// FIX: Make idempotent by deleting existing active reservations first
|
|
656
|
+
// This handles retry scenarios (network timeouts, etc.) without creating duplicates
|
|
657
|
+
if (event.paths.length > 0) {
|
|
658
|
+
await db.query(
|
|
659
|
+
`DELETE FROM reservations
|
|
660
|
+
WHERE project_key = $1
|
|
661
|
+
AND agent_name = $2
|
|
662
|
+
AND path_pattern = ANY($3)
|
|
663
|
+
AND released_at IS NULL`,
|
|
664
|
+
[event.project_key, event.agent_name, event.paths],
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
483
668
|
await db.query(
|
|
484
669
|
`INSERT INTO reservations (project_key, agent_name, path_pattern, exclusive, reason, created_at, expires_at)
|
|
485
670
|
VALUES ${values}`,
|