opencode-swarm-plugin 0.36.0 → 0.37.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/.hive/issues.jsonl +16 -4
- package/.hive/memories.jsonl +274 -1
- package/.turbo/turbo-build.log +4 -4
- package/.turbo/turbo-test.log +318 -318
- package/CHANGELOG.md +113 -0
- package/bin/swarm.test.ts +106 -0
- package/bin/swarm.ts +413 -179
- package/dist/compaction-hook.d.ts +54 -4
- package/dist/compaction-hook.d.ts.map +1 -1
- package/dist/eval-capture.d.ts +122 -17
- package/dist/eval-capture.d.ts.map +1 -1
- package/dist/index.d.ts +1 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1278 -619
- package/dist/planning-guardrails.d.ts +121 -0
- package/dist/planning-guardrails.d.ts.map +1 -1
- package/dist/plugin.d.ts +9 -9
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +1283 -329
- package/dist/schemas/task.d.ts +0 -1
- package/dist/schemas/task.d.ts.map +1 -1
- package/dist/swarm-decompose.d.ts +0 -8
- package/dist/swarm-decompose.d.ts.map +1 -1
- package/dist/swarm-orchestrate.d.ts.map +1 -1
- package/dist/swarm-prompts.d.ts +0 -4
- package/dist/swarm-prompts.d.ts.map +1 -1
- package/dist/swarm-review.d.ts.map +1 -1
- package/dist/swarm.d.ts +0 -6
- package/dist/swarm.d.ts.map +1 -1
- package/evals/README.md +38 -0
- package/evals/coordinator-session.eval.ts +154 -0
- package/evals/fixtures/coordinator-sessions.ts +328 -0
- package/evals/lib/data-loader.ts +69 -0
- package/evals/scorers/coordinator-discipline.evalite-test.ts +536 -0
- package/evals/scorers/coordinator-discipline.ts +315 -0
- package/evals/scorers/index.ts +12 -0
- package/examples/plugin-wrapper-template.ts +303 -4
- package/package.json +2 -2
- package/src/compaction-hook.test.ts +8 -1
- package/src/compaction-hook.ts +31 -21
- package/src/eval-capture.test.ts +390 -0
- package/src/eval-capture.ts +163 -4
- package/src/hive.integration.test.ts +148 -0
- package/src/hive.ts +89 -0
- package/src/index.ts +68 -1
- package/src/planning-guardrails.test.ts +387 -2
- package/src/planning-guardrails.ts +289 -0
- package/src/plugin.ts +10 -10
- package/src/swarm-decompose.test.ts +195 -0
- package/src/swarm-decompose.ts +72 -1
- package/src/swarm-orchestrate.ts +44 -0
- package/src/swarm-prompts.ts +20 -0
- package/src/swarm-review.integration.test.ts +24 -29
- package/src/swarm-review.ts +41 -0
package/src/plugin.ts
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OpenCode Plugin Entry Point
|
|
3
3
|
*
|
|
4
|
-
* CRITICAL: Only export the plugin function from this file.
|
|
4
|
+
* CRITICAL: Only export the plugin function as DEFAULT from this file.
|
|
5
5
|
*
|
|
6
6
|
* OpenCode's plugin loader calls ALL exports as functions during initialization.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* If you export both named AND default pointing to the same function, the plugin
|
|
8
|
+
* gets registered TWICE, causing hooks to fire multiple times.
|
|
9
9
|
*
|
|
10
10
|
* If you need to export utilities for external use, add them to src/index.ts instead.
|
|
11
11
|
*
|
|
12
12
|
* @example
|
|
13
|
-
* // ✅ CORRECT - only export
|
|
13
|
+
* // ✅ CORRECT - only default export
|
|
14
14
|
* export default SwarmPlugin;
|
|
15
15
|
*
|
|
16
|
-
* // ❌ WRONG -
|
|
17
|
-
* export
|
|
18
|
-
* export
|
|
16
|
+
* // ❌ WRONG - causes double registration
|
|
17
|
+
* export { SwarmPlugin };
|
|
18
|
+
* export default SwarmPlugin;
|
|
19
19
|
*/
|
|
20
|
-
import
|
|
20
|
+
import SwarmPlugin from "./index";
|
|
21
21
|
|
|
22
|
-
// Only export
|
|
23
|
-
export
|
|
22
|
+
// Only default export - no named exports!
|
|
23
|
+
export default SwarmPlugin;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Swarm Decompose Unit Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for task decomposition, validation, and eval capture integration.
|
|
5
|
+
*
|
|
6
|
+
* TDD: Testing eval capture integration - verifies captureDecomposition() is called
|
|
7
|
+
* after successful validation with correct parameters.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test, mock } from "bun:test";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import { swarm_validate_decomposition } from "./swarm-decompose";
|
|
12
|
+
import * as evalCapture from "./eval-capture.js";
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Test Setup
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
const mockContext = {
|
|
19
|
+
sessionID: `test-decompose-${Date.now()}`,
|
|
20
|
+
messageID: `test-message-${Date.now()}`,
|
|
21
|
+
agent: "test-agent",
|
|
22
|
+
abort: new AbortController().signal,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
let testProjectPath: string;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
testProjectPath = `/tmp/test-swarm-decompose-${Date.now()}`;
|
|
29
|
+
fs.mkdirSync(testProjectPath, { recursive: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
if (fs.existsSync(testProjectPath)) {
|
|
34
|
+
fs.rmSync(testProjectPath, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Eval Capture Integration Tests
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
describe("captureDecomposition integration", () => {
|
|
43
|
+
test("calls captureDecomposition after successful validation with all params", async () => {
|
|
44
|
+
// Mock captureDecomposition to spy on calls
|
|
45
|
+
const captureDecompositionSpy = mock(() => ({
|
|
46
|
+
id: "test-epic-123",
|
|
47
|
+
timestamp: new Date().toISOString(),
|
|
48
|
+
task: "Add user authentication",
|
|
49
|
+
}));
|
|
50
|
+
const original = evalCapture.captureDecomposition;
|
|
51
|
+
// @ts-expect-error - mocking for test
|
|
52
|
+
evalCapture.captureDecomposition = captureDecompositionSpy;
|
|
53
|
+
|
|
54
|
+
const validCellTree = JSON.stringify({
|
|
55
|
+
epic: {
|
|
56
|
+
title: "Add OAuth",
|
|
57
|
+
description: "Implement OAuth authentication",
|
|
58
|
+
},
|
|
59
|
+
subtasks: [
|
|
60
|
+
{
|
|
61
|
+
title: "Add OAuth provider config",
|
|
62
|
+
description: "Set up Google OAuth",
|
|
63
|
+
files: ["src/auth/google.ts", "src/auth/config.ts"],
|
|
64
|
+
dependencies: [],
|
|
65
|
+
estimated_complexity: 2,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
title: "Add login UI",
|
|
69
|
+
description: "Create login button component",
|
|
70
|
+
files: ["src/components/LoginButton.tsx"],
|
|
71
|
+
dependencies: [0],
|
|
72
|
+
estimated_complexity: 1,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const result = await swarm_validate_decomposition.execute(
|
|
78
|
+
{
|
|
79
|
+
response: validCellTree,
|
|
80
|
+
project_path: testProjectPath,
|
|
81
|
+
task: "Add user authentication",
|
|
82
|
+
context: "Using NextAuth.js",
|
|
83
|
+
strategy: "feature-based" as const,
|
|
84
|
+
epic_id: "test-epic-123",
|
|
85
|
+
},
|
|
86
|
+
mockContext,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const parsed = JSON.parse(result);
|
|
90
|
+
expect(parsed.valid).toBe(true);
|
|
91
|
+
|
|
92
|
+
// Verify captureDecomposition was called with correct params
|
|
93
|
+
expect(captureDecompositionSpy).toHaveBeenCalledTimes(1);
|
|
94
|
+
const callArgs = captureDecompositionSpy.mock.calls[0][0];
|
|
95
|
+
expect(callArgs.epicId).toBe("test-epic-123");
|
|
96
|
+
expect(callArgs.projectPath).toBe(testProjectPath);
|
|
97
|
+
expect(callArgs.task).toBe("Add user authentication");
|
|
98
|
+
expect(callArgs.context).toBe("Using NextAuth.js");
|
|
99
|
+
expect(callArgs.strategy).toBe("feature-based");
|
|
100
|
+
expect(callArgs.epicTitle).toBe("Add OAuth");
|
|
101
|
+
expect(callArgs.epicDescription).toBe("Implement OAuth authentication");
|
|
102
|
+
expect(callArgs.subtasks).toHaveLength(2);
|
|
103
|
+
expect(callArgs.subtasks[0].title).toBe("Add OAuth provider config");
|
|
104
|
+
|
|
105
|
+
// Restore
|
|
106
|
+
// @ts-expect-error - restoring mock
|
|
107
|
+
evalCapture.captureDecomposition = original;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("does not call captureDecomposition when validation fails", async () => {
|
|
111
|
+
const captureDecompositionSpy = mock(() => ({}));
|
|
112
|
+
const original = evalCapture.captureDecomposition;
|
|
113
|
+
// @ts-expect-error - mocking for test
|
|
114
|
+
evalCapture.captureDecomposition = captureDecompositionSpy;
|
|
115
|
+
|
|
116
|
+
// Invalid CellTree - missing required fields
|
|
117
|
+
const invalidCellTree = JSON.stringify({
|
|
118
|
+
epic: { title: "Missing subtasks" },
|
|
119
|
+
// No subtasks array
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const result = await swarm_validate_decomposition.execute(
|
|
123
|
+
{
|
|
124
|
+
response: invalidCellTree,
|
|
125
|
+
project_path: testProjectPath,
|
|
126
|
+
task: "Add auth",
|
|
127
|
+
strategy: "auto" as const,
|
|
128
|
+
epic_id: "test-epic-456",
|
|
129
|
+
},
|
|
130
|
+
mockContext,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const parsed = JSON.parse(result);
|
|
134
|
+
expect(parsed.valid).toBe(false);
|
|
135
|
+
|
|
136
|
+
// Verify captureDecomposition was NOT called
|
|
137
|
+
expect(captureDecompositionSpy).not.toHaveBeenCalled();
|
|
138
|
+
|
|
139
|
+
// Restore
|
|
140
|
+
// @ts-expect-error - restoring mock
|
|
141
|
+
evalCapture.captureDecomposition = original;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("handles optional context and description fields", async () => {
|
|
145
|
+
const captureDecompositionSpy = mock(() => ({
|
|
146
|
+
id: "test-epic-789",
|
|
147
|
+
timestamp: new Date().toISOString(),
|
|
148
|
+
task: "Fix the auth bug",
|
|
149
|
+
}));
|
|
150
|
+
const original = evalCapture.captureDecomposition;
|
|
151
|
+
// @ts-expect-error - mocking for test
|
|
152
|
+
evalCapture.captureDecomposition = captureDecompositionSpy;
|
|
153
|
+
|
|
154
|
+
const validCellTree = JSON.stringify({
|
|
155
|
+
epic: {
|
|
156
|
+
title: "Fix bug",
|
|
157
|
+
// No description
|
|
158
|
+
},
|
|
159
|
+
subtasks: [
|
|
160
|
+
{
|
|
161
|
+
title: "Add test",
|
|
162
|
+
files: ["src/test.ts"],
|
|
163
|
+
dependencies: [],
|
|
164
|
+
estimated_complexity: 1,
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const result = await swarm_validate_decomposition.execute(
|
|
170
|
+
{
|
|
171
|
+
response: validCellTree,
|
|
172
|
+
project_path: testProjectPath,
|
|
173
|
+
task: "Fix the auth bug",
|
|
174
|
+
// No context
|
|
175
|
+
strategy: "risk-based" as const,
|
|
176
|
+
epic_id: "test-epic-789",
|
|
177
|
+
},
|
|
178
|
+
mockContext,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const parsed = JSON.parse(result);
|
|
182
|
+
expect(parsed.valid).toBe(true);
|
|
183
|
+
|
|
184
|
+
// Verify captureDecomposition was called without optional fields
|
|
185
|
+
expect(captureDecompositionSpy).toHaveBeenCalledTimes(1);
|
|
186
|
+
const callArgs = captureDecompositionSpy.mock.calls[0][0];
|
|
187
|
+
expect(callArgs.epicId).toBe("test-epic-789");
|
|
188
|
+
expect(callArgs.context).toBeUndefined();
|
|
189
|
+
expect(callArgs.epicDescription).toBeUndefined();
|
|
190
|
+
|
|
191
|
+
// Restore
|
|
192
|
+
// @ts-expect-error - restoring mock
|
|
193
|
+
evalCapture.captureDecomposition = original;
|
|
194
|
+
});
|
|
195
|
+
});
|
package/src/swarm-decompose.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
NEGATIVE_MARKERS,
|
|
21
21
|
type DecompositionStrategy,
|
|
22
22
|
} from "./swarm-strategies";
|
|
23
|
+
import { captureCoordinatorEvent } from "./eval-capture.js";
|
|
23
24
|
|
|
24
25
|
// ============================================================================
|
|
25
26
|
// Decomposition Prompt (temporary - will be moved to swarm-prompts.ts)
|
|
@@ -534,11 +535,31 @@ export const swarm_decompose = tool({
|
|
|
534
535
|
* Use this after the agent responds to swarm:decompose to validate the structure.
|
|
535
536
|
*/
|
|
536
537
|
export const swarm_validate_decomposition = tool({
|
|
537
|
-
description: "Validate a decomposition response against CellTreeSchema",
|
|
538
|
+
description: "Validate a decomposition response against CellTreeSchema and capture for eval",
|
|
538
539
|
args: {
|
|
539
540
|
response: tool.schema
|
|
540
541
|
.string()
|
|
541
542
|
.describe("JSON response from agent (CellTree format)"),
|
|
543
|
+
project_path: tool.schema
|
|
544
|
+
.string()
|
|
545
|
+
.optional()
|
|
546
|
+
.describe("Project path for eval capture"),
|
|
547
|
+
task: tool.schema
|
|
548
|
+
.string()
|
|
549
|
+
.optional()
|
|
550
|
+
.describe("Original task description for eval capture"),
|
|
551
|
+
context: tool.schema
|
|
552
|
+
.string()
|
|
553
|
+
.optional()
|
|
554
|
+
.describe("Context provided for decomposition"),
|
|
555
|
+
strategy: tool.schema
|
|
556
|
+
.enum(["file-based", "feature-based", "risk-based", "auto"])
|
|
557
|
+
.optional()
|
|
558
|
+
.describe("Decomposition strategy used"),
|
|
559
|
+
epic_id: tool.schema
|
|
560
|
+
.string()
|
|
561
|
+
.optional()
|
|
562
|
+
.describe("Epic ID for eval capture"),
|
|
542
563
|
},
|
|
543
564
|
async execute(args) {
|
|
544
565
|
try {
|
|
@@ -596,6 +617,37 @@ export const swarm_validate_decomposition = tool({
|
|
|
596
617
|
validated.subtasks,
|
|
597
618
|
);
|
|
598
619
|
|
|
620
|
+
// Capture decomposition for eval if all required params provided
|
|
621
|
+
if (
|
|
622
|
+
args.project_path &&
|
|
623
|
+
args.task &&
|
|
624
|
+
args.strategy &&
|
|
625
|
+
args.epic_id
|
|
626
|
+
) {
|
|
627
|
+
try {
|
|
628
|
+
const { captureDecomposition } = await import("./eval-capture.js");
|
|
629
|
+
captureDecomposition({
|
|
630
|
+
epicId: args.epic_id,
|
|
631
|
+
projectPath: args.project_path,
|
|
632
|
+
task: args.task,
|
|
633
|
+
context: args.context,
|
|
634
|
+
strategy: args.strategy,
|
|
635
|
+
epicTitle: validated.epic.title,
|
|
636
|
+
epicDescription: validated.epic.description,
|
|
637
|
+
subtasks: validated.subtasks.map((s) => ({
|
|
638
|
+
title: s.title,
|
|
639
|
+
description: s.description,
|
|
640
|
+
files: s.files,
|
|
641
|
+
dependencies: s.dependencies,
|
|
642
|
+
estimated_complexity: s.estimated_complexity,
|
|
643
|
+
})),
|
|
644
|
+
});
|
|
645
|
+
} catch (error) {
|
|
646
|
+
// Non-fatal - don't block validation if capture fails
|
|
647
|
+
console.warn("[swarm_validate_decomposition] Failed to capture decomposition:", error);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
599
651
|
return JSON.stringify(
|
|
600
652
|
{
|
|
601
653
|
valid: true,
|
|
@@ -722,6 +774,25 @@ export const swarm_delegate_planning = tool({
|
|
|
722
774
|
strategyReasoning = selection.reasoning;
|
|
723
775
|
}
|
|
724
776
|
|
|
777
|
+
// Capture strategy selection decision
|
|
778
|
+
try {
|
|
779
|
+
captureCoordinatorEvent({
|
|
780
|
+
session_id: process.env.OPENCODE_SESSION_ID || "unknown",
|
|
781
|
+
epic_id: "planning", // No epic ID yet - this is pre-decomposition
|
|
782
|
+
timestamp: new Date().toISOString(),
|
|
783
|
+
event_type: "DECISION",
|
|
784
|
+
decision_type: "strategy_selected",
|
|
785
|
+
payload: {
|
|
786
|
+
strategy: selectedStrategy,
|
|
787
|
+
reasoning: strategyReasoning,
|
|
788
|
+
task_preview: args.task.slice(0, 100),
|
|
789
|
+
},
|
|
790
|
+
});
|
|
791
|
+
} catch (error) {
|
|
792
|
+
// Non-fatal - don't block planning if capture fails
|
|
793
|
+
console.warn("[swarm_delegate_planning] Failed to capture strategy_selected:", error);
|
|
794
|
+
}
|
|
795
|
+
|
|
725
796
|
// Query CASS for similar past tasks
|
|
726
797
|
let cassContext = "";
|
|
727
798
|
let cassResultInfo: {
|
package/src/swarm-orchestrate.ts
CHANGED
|
@@ -83,6 +83,7 @@ import {
|
|
|
83
83
|
isReviewApproved,
|
|
84
84
|
getReviewStatus,
|
|
85
85
|
} from "./swarm-review";
|
|
86
|
+
import { captureCoordinatorEvent } from "./eval-capture.js";
|
|
86
87
|
|
|
87
88
|
// ============================================================================
|
|
88
89
|
// Helper Functions
|
|
@@ -1709,6 +1710,28 @@ Files touched: ${args.files_touched?.join(", ") || "none recorded"}`,
|
|
|
1709
1710
|
},
|
|
1710
1711
|
};
|
|
1711
1712
|
|
|
1713
|
+
// Capture subtask completion outcome
|
|
1714
|
+
try {
|
|
1715
|
+
const durationMs = args.start_time ? Date.now() - args.start_time : 0;
|
|
1716
|
+
captureCoordinatorEvent({
|
|
1717
|
+
session_id: process.env.OPENCODE_SESSION_ID || "unknown",
|
|
1718
|
+
epic_id: epicId,
|
|
1719
|
+
timestamp: new Date().toISOString(),
|
|
1720
|
+
event_type: "OUTCOME",
|
|
1721
|
+
outcome_type: "subtask_success",
|
|
1722
|
+
payload: {
|
|
1723
|
+
bead_id: args.bead_id,
|
|
1724
|
+
duration_ms: durationMs,
|
|
1725
|
+
files_touched: args.files_touched || [],
|
|
1726
|
+
verification_passed: verificationResult?.passed ?? false,
|
|
1727
|
+
verification_skipped: args.skip_verification ?? false,
|
|
1728
|
+
},
|
|
1729
|
+
});
|
|
1730
|
+
} catch (error) {
|
|
1731
|
+
// Non-fatal - don't block completion if capture fails
|
|
1732
|
+
console.warn("[swarm_complete] Failed to capture subtask_success:", error);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1712
1735
|
return JSON.stringify(response, null, 2);
|
|
1713
1736
|
} catch (error) {
|
|
1714
1737
|
// CRITICAL: Notify coordinator of failure via swarm mail
|
|
@@ -1796,6 +1819,27 @@ Files touched: ${args.files_touched?.join(", ") || "none recorded"}`,
|
|
|
1796
1819
|
console.error(`[swarm_complete] Original error:`, error);
|
|
1797
1820
|
}
|
|
1798
1821
|
|
|
1822
|
+
// Capture subtask failure outcome
|
|
1823
|
+
try {
|
|
1824
|
+
const durationMs = args.start_time ? Date.now() - args.start_time : 0;
|
|
1825
|
+
captureCoordinatorEvent({
|
|
1826
|
+
session_id: process.env.OPENCODE_SESSION_ID || "unknown",
|
|
1827
|
+
epic_id: epicId,
|
|
1828
|
+
timestamp: new Date().toISOString(),
|
|
1829
|
+
event_type: "OUTCOME",
|
|
1830
|
+
outcome_type: "subtask_failed",
|
|
1831
|
+
payload: {
|
|
1832
|
+
bead_id: args.bead_id,
|
|
1833
|
+
duration_ms: durationMs,
|
|
1834
|
+
failed_step: failedStep,
|
|
1835
|
+
error_message: errorMessage.slice(0, 500),
|
|
1836
|
+
},
|
|
1837
|
+
});
|
|
1838
|
+
} catch (captureError) {
|
|
1839
|
+
// Non-fatal - don't block error return if capture fails
|
|
1840
|
+
console.warn("[swarm_complete] Failed to capture subtask_failed:", captureError);
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1799
1843
|
// Return structured error instead of throwing
|
|
1800
1844
|
// This ensures the agent sees the actual error message
|
|
1801
1845
|
return JSON.stringify(
|
package/src/swarm-prompts.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { tool } from "@opencode-ai/plugin";
|
|
16
16
|
import { generateWorkerHandoff } from "./swarm-orchestrate";
|
|
17
|
+
import { captureCoordinatorEvent } from "./eval-capture.js";
|
|
17
18
|
|
|
18
19
|
// ============================================================================
|
|
19
20
|
// Prompt Templates
|
|
@@ -1107,6 +1108,25 @@ export const swarm_spawn_subtask = tool({
|
|
|
1107
1108
|
.replace(/{files_touched}/g, filesJoined)
|
|
1108
1109
|
.replace(/{worker_id}/g, "worker"); // Will be filled by actual worker name
|
|
1109
1110
|
|
|
1111
|
+
// Capture worker spawn decision
|
|
1112
|
+
try {
|
|
1113
|
+
captureCoordinatorEvent({
|
|
1114
|
+
session_id: process.env.OPENCODE_SESSION_ID || "unknown",
|
|
1115
|
+
epic_id: args.epic_id,
|
|
1116
|
+
timestamp: new Date().toISOString(),
|
|
1117
|
+
event_type: "DECISION",
|
|
1118
|
+
decision_type: "worker_spawned",
|
|
1119
|
+
payload: {
|
|
1120
|
+
bead_id: args.bead_id,
|
|
1121
|
+
files: args.files,
|
|
1122
|
+
worker_model: selectedModel,
|
|
1123
|
+
},
|
|
1124
|
+
});
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
// Non-fatal - don't block spawn if capture fails
|
|
1127
|
+
console.warn("[swarm_spawn_subtask] Failed to capture worker_spawned:", error);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1110
1130
|
return JSON.stringify(
|
|
1111
1131
|
{
|
|
1112
1132
|
prompt,
|
|
@@ -2,18 +2,15 @@
|
|
|
2
2
|
* Integration tests for swarm review feedback flow
|
|
3
3
|
*
|
|
4
4
|
* Tests the coordinator review feedback workflow with real HiveAdapter and swarm-mail.
|
|
5
|
-
* Verifies that review approval/rejection properly updates state
|
|
5
|
+
* Verifies that review approval/rejection properly updates state.
|
|
6
6
|
*
|
|
7
|
-
* **
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* - Database path resolution issue between send and receive
|
|
15
|
-
*
|
|
16
|
-
* Tests currently SKIPPED pending message retrieval fix.
|
|
7
|
+
* **ARCHITECTURE**: Coordinator-driven retry pattern (swarm_spawn_retry)
|
|
8
|
+
* - `approved` status: Sends message to worker (worker can complete)
|
|
9
|
+
* - `needs_changes` status: NO message sent (worker is dead, coordinator spawns retry)
|
|
10
|
+
* - After 3 rejections: Task marked blocked, NO message sent
|
|
11
|
+
*
|
|
12
|
+
* This aligns with the "worker is dead" philosophy - failed reviews require coordinator
|
|
13
|
+
* intervention via swarm_spawn_retry, not worker self-retry.
|
|
17
14
|
*/
|
|
18
15
|
|
|
19
16
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
@@ -195,23 +192,23 @@ describe("swarm_review integration", () => {
|
|
|
195
192
|
expect(feedbackParsed.attempt).toBe(1);
|
|
196
193
|
expect(feedbackParsed.remaining_attempts).toBe(2);
|
|
197
194
|
|
|
198
|
-
// Verify
|
|
199
|
-
expect(feedbackParsed.
|
|
200
|
-
|
|
201
|
-
|
|
195
|
+
// Verify retry_context is provided for coordinator to spawn retry
|
|
196
|
+
expect(feedbackParsed.retry_context).toBeDefined();
|
|
197
|
+
expect(feedbackParsed.retry_context.task_id).toBe(subtask.id);
|
|
198
|
+
expect(feedbackParsed.retry_context.attempt).toBe(1);
|
|
199
|
+
expect(feedbackParsed.retry_context.max_attempts).toBe(3);
|
|
200
|
+
expect(feedbackParsed.retry_context.issues).toEqual(issues);
|
|
201
|
+
expect(feedbackParsed.retry_context.next_action).toContain("swarm_spawn_retry");
|
|
202
|
+
|
|
203
|
+
// ARCHITECTURE CHANGE: No longer sends message to worker
|
|
204
|
+
// Worker is considered "dead" - coordinator must spawn retry
|
|
205
|
+
// Inbox should remain empty
|
|
202
206
|
const messages = await swarmMail.getInbox(
|
|
203
207
|
testProjectPath,
|
|
204
208
|
"TestWorker",
|
|
205
209
|
{ limit: 10 }
|
|
206
210
|
);
|
|
207
|
-
expect(messages.length).
|
|
208
|
-
|
|
209
|
-
const needsChangesMessage = messages.find((m) =>
|
|
210
|
-
m.subject.includes("NEEDS CHANGES")
|
|
211
|
-
);
|
|
212
|
-
expect(needsChangesMessage).toBeDefined();
|
|
213
|
-
expect(needsChangesMessage?.subject).toContain(subtask.id);
|
|
214
|
-
expect(needsChangesMessage?.subject).toContain("attempt 1/3");
|
|
211
|
+
expect(messages.length).toBe(0);
|
|
215
212
|
});
|
|
216
213
|
|
|
217
214
|
test("3-strike rule: task marked blocked after 3 rejections", async () => {
|
|
@@ -275,16 +272,14 @@ describe("swarm_review integration", () => {
|
|
|
275
272
|
const updatedCell = await hive.getCell(testProjectPath, subtask.id);
|
|
276
273
|
expect(updatedCell?.status).toBe("blocked");
|
|
277
274
|
|
|
278
|
-
//
|
|
275
|
+
// ARCHITECTURE CHANGE: No longer sends failure message
|
|
276
|
+
// Worker is dead, coordinator handles escalation
|
|
277
|
+
// Inbox should remain empty
|
|
279
278
|
const messages = await swarmMail.getInbox(
|
|
280
279
|
testProjectPath,
|
|
281
280
|
"TestWorker",
|
|
282
281
|
{ limit: 10 }
|
|
283
282
|
);
|
|
284
|
-
|
|
285
|
-
const failedMessage = messages.find((m) => m.subject.includes("FAILED"));
|
|
286
|
-
expect(failedMessage).toBeDefined();
|
|
287
|
-
expect(failedMessage?.subject).toContain("max review attempts reached");
|
|
288
|
-
expect(failedMessage?.importance).toBe("urgent");
|
|
283
|
+
expect(messages.length).toBe(0);
|
|
289
284
|
});
|
|
290
285
|
});
|
package/src/swarm-review.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { tool } from "@opencode-ai/plugin";
|
|
|
18
18
|
import { z } from "zod";
|
|
19
19
|
import { sendSwarmMessage, type HiveAdapter } from "swarm-mail";
|
|
20
20
|
import { getHiveAdapter } from "./hive";
|
|
21
|
+
import { captureCoordinatorEvent } from "./eval-capture.js";
|
|
21
22
|
|
|
22
23
|
// ============================================================================
|
|
23
24
|
// Types & Schemas
|
|
@@ -508,6 +509,25 @@ export const swarm_review_feedback = tool({
|
|
|
508
509
|
// Mark as approved and clear attempts
|
|
509
510
|
markReviewApproved(args.task_id);
|
|
510
511
|
|
|
512
|
+
// Capture review approval decision
|
|
513
|
+
try {
|
|
514
|
+
captureCoordinatorEvent({
|
|
515
|
+
session_id: process.env.OPENCODE_SESSION_ID || "unknown",
|
|
516
|
+
epic_id: epicId,
|
|
517
|
+
timestamp: new Date().toISOString(),
|
|
518
|
+
event_type: "DECISION",
|
|
519
|
+
decision_type: "review_completed",
|
|
520
|
+
payload: {
|
|
521
|
+
task_id: args.task_id,
|
|
522
|
+
status: "approved",
|
|
523
|
+
retry_count: 0,
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
} catch (error) {
|
|
527
|
+
// Non-fatal - don't block approval if capture fails
|
|
528
|
+
console.warn("[swarm_review_feedback] Failed to capture review_completed:", error);
|
|
529
|
+
}
|
|
530
|
+
|
|
511
531
|
// Send approval message
|
|
512
532
|
await sendSwarmMessage({
|
|
513
533
|
projectPath: args.project_key,
|
|
@@ -539,6 +559,27 @@ You may now complete the task with \`swarm_complete\`.`,
|
|
|
539
559
|
const attemptNumber = incrementAttempt(args.task_id);
|
|
540
560
|
const remaining = MAX_REVIEW_ATTEMPTS - attemptNumber;
|
|
541
561
|
|
|
562
|
+
// Capture review rejection decision
|
|
563
|
+
try {
|
|
564
|
+
captureCoordinatorEvent({
|
|
565
|
+
session_id: process.env.OPENCODE_SESSION_ID || "unknown",
|
|
566
|
+
epic_id: epicId,
|
|
567
|
+
timestamp: new Date().toISOString(),
|
|
568
|
+
event_type: "DECISION",
|
|
569
|
+
decision_type: "review_completed",
|
|
570
|
+
payload: {
|
|
571
|
+
task_id: args.task_id,
|
|
572
|
+
status: "needs_changes",
|
|
573
|
+
retry_count: attemptNumber,
|
|
574
|
+
remaining_attempts: remaining,
|
|
575
|
+
issues_count: parsedIssues.length,
|
|
576
|
+
},
|
|
577
|
+
});
|
|
578
|
+
} catch (error) {
|
|
579
|
+
// Non-fatal - don't block feedback if capture fails
|
|
580
|
+
console.warn("[swarm_review_feedback] Failed to capture review_completed:", error);
|
|
581
|
+
}
|
|
582
|
+
|
|
542
583
|
// Check if task should fail
|
|
543
584
|
if (remaining <= 0) {
|
|
544
585
|
// Mark task as blocked using HiveAdapter
|