@polderlabs/bizar-plugin 0.5.4 → 0.6.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/dist/index.js +29901 -0
- package/index.ts +34 -8
- package/package.json +1 -1
- package/src/background-state.ts +15 -4
- package/src/background.ts +19 -1
- package/src/commands-impl.ts +95 -0
- package/src/commands.ts +53 -0
- package/src/plan-fs.ts +2 -2
- package/src/serve-info.ts +228 -0
- package/src/serve.ts +12 -1
- package/tests/attach-handler-bug.test.ts +2 -2
- package/tests/event-stream.test.ts +2 -2
- package/tests/http-client.test.ts +6 -7
- package/tests/init-helpers.test.ts +3 -3
- package/tests/serve.test.ts +14 -10
- package/tests/tools/bg-get-comments.test.ts +2 -2
- package/tests/tools/bg-kill.test.ts +9 -5
- 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
|
@@ -33,10 +33,7 @@ interface Message {
|
|
|
33
33
|
parts: MessagePart[];
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
info: Message;
|
|
38
|
-
parts: MessagePart[];
|
|
39
|
-
}
|
|
36
|
+
type CollectedMessage = Message;
|
|
40
37
|
|
|
41
38
|
// ---------------------------------------------------------------------------
|
|
42
39
|
// Fake HttpClient matching the expected interface
|
|
@@ -185,8 +182,10 @@ describe("HttpClient.sendPrompt", () => {
|
|
|
185
182
|
const msgs = await client.listMessages(session.id, "/tmp");
|
|
186
183
|
const userMsg = msgs.find((m) => m.info.role === "user");
|
|
187
184
|
expect(userMsg).toBeDefined();
|
|
188
|
-
|
|
189
|
-
expect(
|
|
185
|
+
const firstPart = userMsg?.parts[0];
|
|
186
|
+
expect(firstPart).toBeDefined();
|
|
187
|
+
expect(firstPart?.type).toBe("text");
|
|
188
|
+
expect(firstPart?.text).toBe("Do the research on X");
|
|
190
189
|
});
|
|
191
190
|
|
|
192
191
|
it("generates a unique messageID per call (HIGH-2)", async () => {
|
|
@@ -400,4 +399,4 @@ describe("HTTP timeout handling (HIGH-36)", () => {
|
|
|
400
399
|
// This is ensured by the implementation not writing on AbortError
|
|
401
400
|
expect(true).toBe(true);
|
|
402
401
|
});
|
|
403
|
-
});
|
|
402
|
+
});
|
|
@@ -182,10 +182,10 @@ describe("readValidSessionIds — postmortem Layer 1 fix (v0.5.2)", () => {
|
|
|
182
182
|
// no-op `.catch(() => undefined)` to suppress the unhandled-
|
|
183
183
|
// rejection. We can't directly observe unhandled rejections in
|
|
184
184
|
// bun:test, but we can verify the function returns cleanly.
|
|
185
|
-
let rejectFn: ((err: Error) => void) |
|
|
185
|
+
let rejectFn: ((err: Error) => void) | undefined;
|
|
186
186
|
const input = makeInput({
|
|
187
187
|
session: {
|
|
188
|
-
list: () => new Promise((_, reject) => {
|
|
188
|
+
list: () => new Promise((_, reject: (err: Error) => void) => {
|
|
189
189
|
rejectFn = reject;
|
|
190
190
|
}),
|
|
191
191
|
},
|
|
@@ -195,7 +195,7 @@ describe("readValidSessionIds — postmortem Layer 1 fix (v0.5.2)", () => {
|
|
|
195
195
|
// Now reject the hanging promise AFTER the function returned.
|
|
196
196
|
// If the no-op catch is missing, this would be an unhandled
|
|
197
197
|
// rejection. With it, the process keeps running.
|
|
198
|
-
|
|
198
|
+
rejectFn?.(new Error("late rejection"));
|
|
199
199
|
// Give the microtask queue a chance to run.
|
|
200
200
|
await new Promise((r) => setTimeout(r, 10));
|
|
201
201
|
},
|
package/tests/serve.test.ts
CHANGED
|
@@ -155,7 +155,7 @@ describe("ENOENT and EACCES handling", () => {
|
|
|
155
155
|
describe("unexpected exit", () => {
|
|
156
156
|
it("marks all running instances failed when serve child exits unexpectedly", async () => {
|
|
157
157
|
// Simulate: proc.exited resolves with non-zero code (not intentional shutdown)
|
|
158
|
-
const exitCode = 1;
|
|
158
|
+
const exitCode: number = 1;
|
|
159
159
|
const runningInstances = [
|
|
160
160
|
{ instanceId: "bgr_01", status: "running", error: undefined as string | undefined },
|
|
161
161
|
{ instanceId: "bgr_02", status: "pending", error: undefined as string | undefined },
|
|
@@ -171,14 +171,18 @@ describe("unexpected exit", () => {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
expect(
|
|
177
|
-
expect(
|
|
174
|
+
const first = runningInstances[0];
|
|
175
|
+
const second = runningInstances[1];
|
|
176
|
+
expect(first).toBeDefined();
|
|
177
|
+
expect(second).toBeDefined();
|
|
178
|
+
expect(first?.status).toBe("failed");
|
|
179
|
+
expect(first?.error).toBe("serve child exited unexpectedly");
|
|
180
|
+
expect(second?.status).toBe("failed");
|
|
181
|
+
expect(second?.error).toBe("serve child exited unexpectedly");
|
|
178
182
|
});
|
|
179
183
|
|
|
180
184
|
it("clean exit (code 0) does not mark instances failed", async () => {
|
|
181
|
-
const exitCode = 0;
|
|
185
|
+
const exitCode: number = 0;
|
|
182
186
|
const runningInstances = [
|
|
183
187
|
{ instanceId: "bgr_01", status: "running" },
|
|
184
188
|
];
|
|
@@ -189,11 +193,11 @@ describe("unexpected exit", () => {
|
|
|
189
193
|
}
|
|
190
194
|
}
|
|
191
195
|
|
|
192
|
-
expect(runningInstances[0]
|
|
196
|
+
expect(runningInstances[0]?.status).toBe("running"); // unchanged
|
|
193
197
|
});
|
|
194
198
|
|
|
195
199
|
it("intentional shutdown does not trigger unexpected-exit path", async () => {
|
|
196
|
-
const exitCode = 1;
|
|
200
|
+
const exitCode: number = 1;
|
|
197
201
|
const intentionalShutdown = true;
|
|
198
202
|
|
|
199
203
|
const runningInstances = [{ instanceId: "bgr_01", status: "running" }];
|
|
@@ -204,7 +208,7 @@ describe("unexpected exit", () => {
|
|
|
204
208
|
for (const inst of runningInstances) inst.status = "failed";
|
|
205
209
|
}
|
|
206
210
|
|
|
207
|
-
expect(runningInstances[0]
|
|
211
|
+
expect(runningInstances[0]?.status).toBe("running");
|
|
208
212
|
});
|
|
209
213
|
|
|
210
214
|
it("servePID is cleared after unexpected exit", async () => {
|
|
@@ -332,4 +336,4 @@ describe("ServeLifecycle interface contract", () => {
|
|
|
332
336
|
expect(typeof fake.password).toBe("string");
|
|
333
337
|
expect(typeof fake.baseUrl).toBe("string");
|
|
334
338
|
});
|
|
335
|
-
});
|
|
339
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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: "/tmp/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
|
+
});
|