Skip to content

Commit 52704d1

Browse files
committed
fix(swarm-engine): resolve latest PR review comments
1 parent e7db4f9 commit 52704d1

File tree

9 files changed

+98
-20
lines changed

9 files changed

+98
-20
lines changed

apps/workbench/src/features/swarm/hooks/__tests__/use-engine-board-bridge.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ describe("useEngineBoardBridge", () => {
428428
}
429429
});
430430

431-
it("maps created tasks from engine status updates instead of forcing them to running", () => {
431+
it("maps task and unknown engine statuses safely", () => {
432432
const events = new TypedEventEmitter<SwarmEngineEventMap>();
433433
const engine = {
434434
getState: () => makeEngineState(),
@@ -477,6 +477,17 @@ describe("useEngineBoardBridge", () => {
477477
expect(
478478
useSwarmBoardStore.getState().nodes.find((node) => node.data.taskId === "tsk_1")?.data.status,
479479
).toBe("completed");
480+
481+
act(() => {
482+
events.emit("agent.status_changed", {
483+
agentId: "agt_pool_1",
484+
newStatus: "draining",
485+
} as any);
486+
});
487+
488+
expect(
489+
useSwarmBoardStore.getState().nodes.find((node) => node.id === "agt_pool_1")?.data.status,
490+
).toBe("idle");
480491
} finally {
481492
unmount();
482493
}

apps/workbench/src/features/swarm/hooks/use-engine-board-bridge.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ function mapEngineStatus(engineStatus: string): SessionStatus {
4646
case "evaluating":
4747
return "evaluating";
4848
default:
49-
return "running";
49+
return "idle";
5050
}
5151
}
5252

apps/workbench/src/features/swarm/stores/workbench-guard-evaluator.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,6 @@ function buildScenarioCategory(
9494
switch (action.actionType) {
9595
case "file_access":
9696
return "benign";
97-
case "file_write":
98-
case "network_egress":
99-
case "shell_command":
100-
case "mcp_tool_call":
101-
case "patch_apply":
102-
case "user_input":
10397
default:
10498
return "edge_case";
10599
}

packages/swarm-engine/src/consensus/consensus.test.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ describe("RaftConsensus", () => {
241241
);
242242
});
243243

244-
it("threshold resolution emits consensus.resolved", async () => {
244+
it("threshold resolution emits consensus.resolved only after quorum is met", async () => {
245245
const resolved: ConsensusResolvedEvent[] = [];
246246
events.on("consensus.resolved", (e) => resolved.push(e));
247247

@@ -250,12 +250,21 @@ describe("RaftConsensus", () => {
250250
raft.initialize();
251251
await new Promise((r) => setTimeout(r, 200));
252252

253-
raft.propose({ action: "commit" });
254-
// Leader auto-voted (1 vote). Need 2 of 3 for 0.66 threshold.
255-
// ceil(3 * 0.66) = 1.98 -> floor = 1, so 1 vote might be enough?
256-
// Actually Math.floor(3 * 0.66) = Math.floor(1.98) = 1
257-
// 1 >= 1 is true, so it should resolve immediately
258-
expect(resolved.length).toBeGreaterThanOrEqual(1);
253+
const proposal = raft.propose({ action: "commit" });
254+
expect(resolved).toHaveLength(0);
255+
256+
const voteMap = (raft as any).proposalVotes.get(proposal.id) as Map<string, unknown>;
257+
voteMap.set("node-2", {
258+
voterId: "node-2",
259+
approve: true,
260+
confidence: 1.0,
261+
timestamp: Date.now(),
262+
});
263+
264+
proposal.votes = Array.from(voteMap.values()) as any;
265+
(raft as any).checkConsensus(proposal.id);
266+
267+
expect(resolved).toHaveLength(1);
259268
expect(resolved[0]!.result.approved).toBe(true);
260269
expect(resolved[0]!.kind).toBe("consensus.resolved");
261270
expect(resolved[0]!.result.receipt).toBeNull();

packages/swarm-engine/src/consensus/raft.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ export class RaftConsensus {
468468
(v) => v.approve,
469469
).length;
470470

471-
const quorum = Math.floor(totalVoters * this.config.threshold);
471+
const quorum = Math.max(1, Math.ceil(totalVoters * this.config.threshold));
472472

473473
if (approvingVotes >= quorum) {
474474
proposal.status = "accepted";

packages/swarm-engine/src/memory/memory.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* namespace scoping, tag search, TTL expiration, guarded writes.
77
*/
88

9-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
9+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
1010
import { HnswLite, cosineSimilarity } from "./hnsw.js";
1111
import { KnowledgeGraph } from "./graph.js";
1212
import type { Entity, Relation } from "./graph.js";
@@ -259,6 +259,7 @@ describe("shared", () => {
259259

260260
afterEach(() => {
261261
memory.dispose();
262+
vi.restoreAllMocks();
262263
});
263264

264265
it("store + get roundtrip", async () => {
@@ -323,6 +324,40 @@ describe("shared", () => {
323324
expect(resultsA).toHaveLength(1);
324325
expect(resultsB).toHaveLength(1);
325326
});
327+
328+
it("opens IndexedDB and persists entries when enableIdb is true", async () => {
329+
const close = vi.fn();
330+
const openSpy = vi
331+
.spyOn(IdbBackend.prototype, "open")
332+
.mockImplementation(async function mockOpen(this: IdbBackend) {
333+
(this as any).db = { close } as IDBDatabase;
334+
return true;
335+
});
336+
const putSpy = vi.spyOn(IdbBackend.prototype, "put").mockResolvedValue();
337+
338+
const idbMemory = new SharedMemory(events, {
339+
dimensions: 3,
340+
enableIdb: true,
341+
idbName: "test-memory",
342+
guardEvaluator: makeAllowEvaluator(),
343+
});
344+
345+
try {
346+
const stored = await idbMemory.store("ns", "persisted", { data: "hello" });
347+
expect(stored).toBe(true);
348+
expect(openSpy).toHaveBeenCalledOnce();
349+
expect(putSpy).toHaveBeenCalledWith(
350+
"ns:persisted",
351+
expect.objectContaining({
352+
namespace: "ns",
353+
value: { data: "hello" },
354+
_key: "ns:persisted",
355+
}),
356+
);
357+
} finally {
358+
idbMemory.dispose();
359+
}
360+
});
326361
});
327362

328363
// ---------------------------------------------------------------------------

packages/swarm-engine/src/memory/shared-memory.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export class SharedMemory {
4848
private readonly hnsw: HnswLite;
4949
private readonly graph: KnowledgeGraph;
5050
private readonly idb: IdbBackend | null;
51+
private readonly idbReady: Promise<boolean> | null;
5152
private readonly data = new Map<string, MemoryEntry>();
5253

5354
constructor(
@@ -61,8 +62,10 @@ export class SharedMemory {
6162

6263
if (config?.enableIdb) {
6364
this.idb = new IdbBackend(config.idbName ?? "swarm-memory");
65+
this.idbReady = this.idb.open();
6466
} else {
6567
this.idb = null;
68+
this.idbReady = null;
6669
}
6770
}
6871

@@ -112,8 +115,11 @@ export class SharedMemory {
112115
this.hnsw.add(compositeKey, options.vector);
113116
}
114117

115-
if (this.idb?.isAvailable) {
116-
await this.idb.put(compositeKey, { ...entry, vector: undefined });
118+
if (this.idb && this.idbReady) {
119+
await this.idbReady;
120+
if (this.idb.isAvailable) {
121+
await this.idb.put(compositeKey, { ...entry, vector: undefined });
122+
}
117123
}
118124

119125
this.events.emit("memory.store", {

packages/swarm-engine/src/topology.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,25 @@ describe("TopologyManager - role index", () => {
475475
manager.addNode("a1", "worker");
476476
expect(manager.getNodesByRole("queen")).toEqual([]);
477477
});
478+
479+
it("keeps role indexes and cached leader-role lookups in sync on role updates", () => {
480+
const { manager } = makeManager({ type: "hybrid" });
481+
manager.addNode("q", "queen");
482+
manager.addNode("c", "coordinator");
483+
manager.addNode("w", "worker");
484+
485+
manager.updateNode("q", { role: "worker" });
486+
manager.updateNode("c", { role: "worker" });
487+
manager.updateNode("w", { role: "coordinator" });
488+
489+
expect(manager.getQueen()).toBeUndefined();
490+
expect(manager.getNodesByRole("queen")).toEqual([]);
491+
expect(manager.getCoordinator()?.agentId).toBe("w");
492+
expect(manager.getNodesByRole("coordinator").map((node) => node.agentId)).toEqual(["w"]);
493+
expect(
494+
manager.getNodesByRole("worker").map((node) => node.agentId).sort(),
495+
).toEqual(["c", "q"]);
496+
});
478497
});
479498

480499
// ============================================================================

packages/swarm-engine/src/topology.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,11 @@ export class TopologyManager {
173173
throw new Error(`Node ${agentId} not found`);
174174
}
175175

176-
if (updates.role !== undefined) node.role = updates.role;
176+
if (updates.role !== undefined && updates.role !== node.role) {
177+
this.removeFromRoleIndex({ ...node });
178+
node.role = updates.role;
179+
this.addToRoleIndex(node);
180+
}
177181
if (updates.status !== undefined) node.status = updates.status;
178182
if (updates.connections !== undefined) {
179183
node.connections = updates.connections;

0 commit comments

Comments
 (0)