@polderlabs/bizar-plugin 0.5.4 → 0.6.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 +1 -1
- package/dist/index.js +29901 -0
- package/index.ts +94 -11
- package/package.json +1 -1
- package/src/background-state.ts +56 -4
- package/src/background.ts +166 -12
- package/src/commands-impl.ts +95 -0
- package/src/commands.ts +321 -91
- package/src/plan-fs.ts +2 -2
- package/src/reasoning-clean.ts +360 -0
- package/src/serve-info.ts +228 -0
- package/src/serve.ts +24 -4
- package/src/tools/bg-spawn.ts +21 -1
- package/tests/attach-handler-bug.test.ts +7 -5
- package/tests/background-state.test.ts +1 -1
- package/tests/background.test.ts +1 -1
- package/tests/block.test.ts +3 -1
- package/tests/canonical-key-order.test.ts +11 -7
- package/tests/event-stream.test.ts +2 -2
- package/tests/event.test.ts +1 -1
- package/tests/fingerprint.test.ts +22 -21
- package/tests/http-client.test.ts +11 -10
- package/tests/init-helpers.test.ts +3 -3
- package/tests/options.test.ts +10 -8
- package/tests/serve.test.ts +14 -10
- package/tests/settings.test.ts +2 -2
- package/tests/stall-think.test.ts +13 -12
- package/tests/state.test.ts +2 -1
- package/tests/tools/bg-get-comments.test.ts +2 -2
- package/tests/tools/bg-kill.test.ts +9 -5
- package/tests/tools/bg-spawn.test.ts +12 -12
- package/tests/tools/bg-status.test.ts +2 -1
- package/tests/tools/plan-action.test.ts +2 -2
- package/tests/tools/wait-for-feedback.test.ts +2 -2
- package/tests/update-deadlock.test.ts +144 -0
package/tests/state.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
|
9
9
|
import { StateStore, SessionState, EMPTY_STATE } from "../src/state";
|
|
10
10
|
import { mkdirSync, rmSync, writeFileSync, existsSync, utimesSync } from "node:fs";
|
|
11
11
|
import path from "node:path";
|
|
12
|
+
import os from "node:os";
|
|
12
13
|
|
|
13
14
|
// Minimal mock logger that collects all messages
|
|
14
15
|
class MockLogger {
|
|
@@ -18,7 +19,7 @@ class MockLogger {
|
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
const TEST_DIR = "
|
|
22
|
+
const TEST_DIR = path.join(os.tmpdir(), "bizar-state-test");
|
|
22
23
|
const TEST_SESSION_A = "session-a-123";
|
|
23
24
|
const TEST_SESSION_B = "session-b-456";
|
|
24
25
|
|
|
@@ -31,7 +31,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
|
31
31
|
import { join } from "node:path";
|
|
32
32
|
import { tmpdir } from "node:os";
|
|
33
33
|
|
|
34
|
-
import { readPlanComments } from "../../src/tools/bg-get-comments.
|
|
34
|
+
import { readPlanComments } from "../../src/tools/bg-get-comments.js";
|
|
35
35
|
|
|
36
36
|
// ---------------------------------------------------------------------------
|
|
37
37
|
// Fixtures / helpers
|
|
@@ -482,4 +482,4 @@ describe("readPlanComments — thread replies", () => {
|
|
|
482
482
|
expect(result.comments[0]?.thread).toBeUndefined();
|
|
483
483
|
}
|
|
484
484
|
});
|
|
485
|
-
});
|
|
485
|
+
});
|
|
@@ -121,16 +121,20 @@ describe("bizar_kill — POST /session/{id}/abort (HIGH-4)", () => {
|
|
|
121
121
|
const instances = makeInstances();
|
|
122
122
|
bizar_kill({ instanceId: "bgr_running" }, instances);
|
|
123
123
|
expect(httpCalls).toHaveLength(1);
|
|
124
|
-
|
|
125
|
-
expect(
|
|
124
|
+
const first = httpCalls[0];
|
|
125
|
+
expect(first).toBeDefined();
|
|
126
|
+
expect(first?.method).toBe("POST");
|
|
127
|
+
expect(first?.path).toContain("/abort");
|
|
126
128
|
});
|
|
127
129
|
|
|
128
130
|
it("calls POST /session/{id}/abort for pending instance", () => {
|
|
129
131
|
const instances = makeInstances();
|
|
130
132
|
bizar_kill({ instanceId: "bgr_pending" }, instances);
|
|
131
133
|
expect(httpCalls).toHaveLength(1);
|
|
132
|
-
|
|
133
|
-
expect(
|
|
134
|
+
const first = httpCalls[0];
|
|
135
|
+
expect(first).toBeDefined();
|
|
136
|
+
expect(first?.method).toBe("POST");
|
|
137
|
+
expect(first?.path).toContain("/abort");
|
|
134
138
|
});
|
|
135
139
|
|
|
136
140
|
it("does NOT call DELETE /session/{id} (HIGH-4)", () => {
|
|
@@ -228,4 +232,4 @@ describe("bizar_kill — unknown instance", () => {
|
|
|
228
232
|
expect(result).toHaveProperty("error");
|
|
229
233
|
expect((result as { error: string }).error).toContain("not found");
|
|
230
234
|
});
|
|
231
|
-
});
|
|
235
|
+
});
|
|
@@ -27,13 +27,13 @@ function parseModel(model: string | undefined): { providerID: string; modelID: s
|
|
|
27
27
|
const parts = model.split("/");
|
|
28
28
|
if (parts.length !== 2) {
|
|
29
29
|
throw new Error(
|
|
30
|
-
`model must be in "providerID/modelID" format (e.g. "minimax
|
|
30
|
+
`model must be in "providerID/modelID" format (e.g. "openrouter/minimax-m3"). Omit to use the agent's default.`,
|
|
31
31
|
);
|
|
32
32
|
}
|
|
33
33
|
const [providerID, modelID] = parts;
|
|
34
34
|
if (!providerID || !modelID) {
|
|
35
35
|
throw new Error(
|
|
36
|
-
`model must be in "providerID/modelID" format (e.g. "minimax
|
|
36
|
+
`model must be in "providerID/modelID" format (e.g. "openrouter/minimax-m3"). Omit to use the agent's default.`,
|
|
37
37
|
);
|
|
38
38
|
}
|
|
39
39
|
return { providerID, modelID };
|
|
@@ -132,9 +132,9 @@ describe("bizar_spawn_background — Odin-only", () => {
|
|
|
132
132
|
// ---------------------------------------------------------------------------
|
|
133
133
|
|
|
134
134
|
describe("bizar_spawn_background — model parsing (HIGH-3, LOW-34)", () => {
|
|
135
|
-
it('"minimax
|
|
136
|
-
const result = parseModel("minimax
|
|
137
|
-
expect(result).toEqual({ providerID: "
|
|
135
|
+
it('"openrouter/minimax-m3" parses to { providerID: "openrouter", modelID: "minimax-m3" }', () => {
|
|
136
|
+
const result = parseModel("openrouter/minimax-m3");
|
|
137
|
+
expect(result).toEqual({ providerID: "openrouter", modelID: "minimax-m3" });
|
|
138
138
|
});
|
|
139
139
|
|
|
140
140
|
it('"opencode/deepseek-v4-flash-free" parses correctly', () => {
|
|
@@ -142,20 +142,20 @@ describe("bizar_spawn_background — model parsing (HIGH-3, LOW-34)", () => {
|
|
|
142
142
|
expect(result).toEqual({ providerID: "opencode", modelID: "deepseek-v4-flash-free" });
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
-
it('"
|
|
146
|
-
expect(() => parseModel("
|
|
145
|
+
it('"minimax-m3" (no /) is rejected', () => {
|
|
146
|
+
expect(() => parseModel("minimax-m3")).toThrow();
|
|
147
147
|
});
|
|
148
148
|
|
|
149
149
|
it('"a/b/c" (multiple /) is rejected', () => {
|
|
150
150
|
expect(() => parseModel("a/b/c")).toThrow();
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
-
it('"
|
|
154
|
-
expect(() => parseModel("
|
|
153
|
+
it('"openrouter/" (empty modelID) is rejected', () => {
|
|
154
|
+
expect(() => parseModel("openrouter/")).toThrow();
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
it('"/
|
|
158
|
-
expect(() => parseModel("/
|
|
157
|
+
it('"/minimax-m3" (empty providerID) is rejected', () => {
|
|
158
|
+
expect(() => parseModel("/minimax-m3")).toThrow();
|
|
159
159
|
});
|
|
160
160
|
|
|
161
161
|
it("undefined model returns undefined (agent uses its default)", () => {
|
|
@@ -168,7 +168,7 @@ describe("bizar_spawn_background — model parsing (HIGH-3, LOW-34)", () => {
|
|
|
168
168
|
|
|
169
169
|
it("spawn tool includes parsed model in POST /session body", () => {
|
|
170
170
|
const result = bizarre_spawn_background(
|
|
171
|
-
{ agent: "mimir", prompt: "Do X", model: "minimax
|
|
171
|
+
{ agent: "mimir", prompt: "Do X", model: "openrouter/minimax-m3" },
|
|
172
172
|
{ agent: "odin", sessionID: "sess_parent", worktree: "/tmp" },
|
|
173
173
|
);
|
|
174
174
|
expect(result).not.toHaveProperty("error");
|
|
@@ -16,6 +16,7 @@ interface BackgroundState {
|
|
|
16
16
|
agent: string;
|
|
17
17
|
status: "pending" | "running" | "done" | "failed" | "killed" | "timed_out";
|
|
18
18
|
startedAt: number;
|
|
19
|
+
completedAt?: number;
|
|
19
20
|
toolCallCount: number;
|
|
20
21
|
promptPreview: string;
|
|
21
22
|
resultPreview?: string;
|
|
@@ -213,4 +214,4 @@ describe("bizar_status — durationMs", () => {
|
|
|
213
214
|
const expectedDuration = completedAt - startedAt;
|
|
214
215
|
expect(expectedDuration).toBe(30_000);
|
|
215
216
|
});
|
|
216
|
-
});
|
|
217
|
+
});
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
import { join } from "node:path";
|
|
37
37
|
import { tmpdir } from "node:os";
|
|
38
38
|
|
|
39
|
-
import { planAction } from "../../src/tools/plan-action.
|
|
39
|
+
import { planAction } from "../../src/tools/plan-action.js";
|
|
40
40
|
|
|
41
41
|
// ---------------------------------------------------------------------------
|
|
42
42
|
// Fixtures
|
|
@@ -596,4 +596,4 @@ describe("planAction — unknown action", () => {
|
|
|
596
596
|
expect(r.ok).toBe(false);
|
|
597
597
|
if (!r.ok) expect(r.error).toMatch(/Unknown action/);
|
|
598
598
|
});
|
|
599
|
-
});
|
|
599
|
+
});
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
import { join } from "node:path";
|
|
29
29
|
import { tmpdir } from "node:os";
|
|
30
30
|
|
|
31
|
-
import { waitForFeedback } from "../../src/tools/wait-for-feedback.
|
|
31
|
+
import { waitForFeedback } from "../../src/tools/wait-for-feedback.js";
|
|
32
32
|
|
|
33
33
|
// ---------------------------------------------------------------------------
|
|
34
34
|
// Fixtures
|
|
@@ -387,4 +387,4 @@ describe("waitForFeedback — missing plan", () => {
|
|
|
387
387
|
expect(r.error).toMatch(/Plan not found/);
|
|
388
388
|
}
|
|
389
389
|
});
|
|
390
|
-
});
|
|
390
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the background-state mutex.
|
|
3
|
+
*
|
|
4
|
+
* BUG: `InstanceManager.update()` acquired the per-instance lock and then
|
|
5
|
+
* called `BackgroundStateStore.save()`, which tried to acquire the same lock
|
|
6
|
+
* again. That nested re-entry never resolved, so startup deadlocked while
|
|
7
|
+
* `rebuildInMemoryMap()` tried to mark pending/running instances failed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
import { InstanceManager, type AddDraft } from "../src/background.js";
|
|
16
|
+
import {
|
|
17
|
+
BackgroundStateStore,
|
|
18
|
+
type BackgroundState,
|
|
19
|
+
} from "../src/background-state.js";
|
|
20
|
+
|
|
21
|
+
const TEST_DIRS: string[] = [];
|
|
22
|
+
|
|
23
|
+
const logger = {
|
|
24
|
+
log: () => {},
|
|
25
|
+
debug: () => {},
|
|
26
|
+
info: () => {},
|
|
27
|
+
warn: () => {},
|
|
28
|
+
error: () => {},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
class FakeEventStream {
|
|
32
|
+
onSessionEvent(): () => void {
|
|
33
|
+
return () => {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeTempStateDir(): string {
|
|
38
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "bizar-update-deadlock-"));
|
|
39
|
+
TEST_DIRS.push(dir);
|
|
40
|
+
return dir;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeDraft(overrides: Partial<AddDraft> = {}): AddDraft {
|
|
44
|
+
return {
|
|
45
|
+
instanceId: "bgr_deadlock_test",
|
|
46
|
+
sessionId: "",
|
|
47
|
+
agent: "odin",
|
|
48
|
+
model: "agent-default",
|
|
49
|
+
promptPreview: "Investigate startup deadlock",
|
|
50
|
+
resultPreview: undefined,
|
|
51
|
+
resultMessageIds: undefined,
|
|
52
|
+
error: undefined,
|
|
53
|
+
parentAgent: "odin",
|
|
54
|
+
parentInstanceId: undefined,
|
|
55
|
+
logPath: path.join(os.tmpdir(), "bgr_deadlock_test.log"),
|
|
56
|
+
timeoutMs: 30_000,
|
|
57
|
+
toolCallCount: 0,
|
|
58
|
+
loopGuardTool: undefined,
|
|
59
|
+
lastEventAt: undefined,
|
|
60
|
+
lastToolOrTextAt: undefined,
|
|
61
|
+
interventionCount: undefined,
|
|
62
|
+
interventionAt: undefined,
|
|
63
|
+
interventionReason: undefined,
|
|
64
|
+
...overrides,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createManager(stateDir: string): {
|
|
69
|
+
manager: InstanceManager;
|
|
70
|
+
stateStore: BackgroundStateStore;
|
|
71
|
+
} {
|
|
72
|
+
const stateStore = new BackgroundStateStore(stateDir, logger);
|
|
73
|
+
const manager = new InstanceManager({
|
|
74
|
+
stateStore,
|
|
75
|
+
maxConcurrent: 8,
|
|
76
|
+
toolCallCap: 250,
|
|
77
|
+
logger,
|
|
78
|
+
serve: { worktree: "/tmp" } as never,
|
|
79
|
+
http: {} as never,
|
|
80
|
+
stream: new FakeEventStream() as never,
|
|
81
|
+
stallTimeoutMs: 180_000,
|
|
82
|
+
thinkingLoopTimeoutMs: 300_000,
|
|
83
|
+
maxInterventions: 1,
|
|
84
|
+
});
|
|
85
|
+
manager.disablePeriodicChecks();
|
|
86
|
+
return { manager, stateStore };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function settleWithin<T>(promise: Promise<T>, timeoutMs: number): Promise<T | "timeout"> {
|
|
90
|
+
return await Promise.race([
|
|
91
|
+
promise,
|
|
92
|
+
new Promise<"timeout">((resolve) => {
|
|
93
|
+
setTimeout(() => resolve("timeout"), timeoutMs);
|
|
94
|
+
}),
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
for (const dir of TEST_DIRS.splice(0)) {
|
|
100
|
+
rmSync(dir, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("InstanceManager.update mutex regression", () => {
|
|
105
|
+
it("resolves and persists instead of deadlocking on nested save", async () => {
|
|
106
|
+
const stateDir = makeTempStateDir();
|
|
107
|
+
const { manager, stateStore } = createManager(stateDir);
|
|
108
|
+
const draft = makeDraft();
|
|
109
|
+
|
|
110
|
+
await manager.add(draft);
|
|
111
|
+
|
|
112
|
+
const result = await settleWithin(
|
|
113
|
+
manager.update(draft.instanceId, {
|
|
114
|
+
status: "failed",
|
|
115
|
+
error: "plugin restarted while instance was pending",
|
|
116
|
+
}),
|
|
117
|
+
250,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(result).not.toBe("timeout");
|
|
121
|
+
|
|
122
|
+
const stored = await stateStore.load(draft.instanceId);
|
|
123
|
+
expect(stored?.status).toBe("failed");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("rebuildInMemoryMap marks a pending instance failed without hanging", async () => {
|
|
127
|
+
const stateDir = makeTempStateDir();
|
|
128
|
+
const { manager, stateStore } = createManager(stateDir);
|
|
129
|
+
const pendingState: BackgroundState = {
|
|
130
|
+
...makeDraft(),
|
|
131
|
+
status: "pending",
|
|
132
|
+
startedAt: Date.now(),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
await stateStore.save(pendingState);
|
|
136
|
+
|
|
137
|
+
const result = await settleWithin(manager.rebuildInMemoryMap(), 250);
|
|
138
|
+
|
|
139
|
+
expect(result).not.toBe("timeout");
|
|
140
|
+
|
|
141
|
+
const stored = await stateStore.load(pendingState.instanceId);
|
|
142
|
+
expect(stored?.error).toBe("plugin restarted while instance was pending");
|
|
143
|
+
});
|
|
144
|
+
});
|