@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.
@@ -33,10 +33,7 @@ interface Message {
33
33
  parts: MessagePart[];
34
34
  }
35
35
 
36
- interface CollectedMessage {
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
- expect(userMsg!.parts[0].type).toBe("text");
189
- expect(userMsg!.parts[0].text).toBe("Do the research on X");
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) | null = null;
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
- if (rejectFn) rejectFn(new Error("late rejection"));
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
  },
@@ -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
- expect(runningInstances[0].status).toBe("failed");
175
- expect(runningInstances[0].error).toBe("serve child exited unexpectedly");
176
- expect(runningInstances[1].status).toBe("failed");
177
- expect(runningInstances[1].error).toBe("serve child exited unexpectedly");
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].status).toBe("running"); // unchanged
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].status).toBe("running");
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.ts";
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
- expect(httpCalls[0].method).toBe("POST");
125
- expect(httpCalls[0].path).toContain("/abort");
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
- expect(httpCalls[0].method).toBe("POST");
133
- expect(httpCalls[0].path).toContain("/abort");
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.ts";
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.ts";
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
+ });