← reports

#4675 · Persisted task comments are gated on agent delivery

BugPriority: MediumEffort: Lowplugins · tasks · perfGitHub issueOctober 2, 2026 · base 049d6a932ebae9e9060eb9fdd08030f7cc597d28

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

Saving a task comment with agent notification enabled can leave the caller waiting even though the new comment is already in SQLite. The Tasks RPC handler awaits the notification helper before returning the comment or publishing its change event. Either a pending thread lookup or a pending message send holds that response open. A controlled delivery gate reproduces this behavior in two separate clean main checkouts; no live provider or production data is involved.

2. Claims vs findings

ClaimStatusEvidence
Agent delivery blocks the save response after persistence.VerifiedBoth blocked-get and blocked-send tests find two committed comments, but the save promise has not returned.
The initial comment change event is behind delivery.Verified by sourceThe only initial publish is after the awaited delivery in the base implementation (lines 430–443).
Rejected delivery leaves notification count at zero.VerifiedThe existing helper catches thread failures and returns zero; the added post-fix RPC failure test checks persisted data and a warning.
CLI JSON needs a final notification count.VerifiedThe existing CLI regression asserts notifiedCount=1; the complete CLI suite still passes with explicit waiting.
Busy-server latency and slower comment-list opening.Unverified at runtimeNo load benchmark or live UI/HTTP run was performed. Comment-list optimization is outside this fix.

3. Environment

Public repository get-bb/bb; main base 049d6a932ebae9e9060eb9fdd08030f7cc597d28; Linux x86_64; Node v22.19.0; pnpm 9.15.0; Vitest 4.1.1; Tasks 0.1.2; Plugin SDK 0.6.13; better-sqlite3 12.10.0. The official plugin-host test harness creates a fresh temporary storage directory and a real SQLite database. Only its thread transport is controlled. No HTTP ports, enrolled machines, live providers, or personal runtime data were used.

4. Minimal reproduction

  1. Start from the pinned public main commit.
  2. Install frozen dependencies and build the Tasks subsystem.
  3. Apply the supplied test-only patch. It adds two cases to the existing typed RPC owner test.
  4. Run the filtered Turbo command below. Each test commits an earlier agent reply, starts a notified save, leaves either lookup or send pending, then checks persistence and response completion after an event-loop turn.
git clone https://github.com/get-bb/bb.git repro-4675
cd repro-4675
git checkout 049d6a932ebae9e9060eb9fdd08030f7cc597d28
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build --filter=bb-plugin-tasks
curl -fsS https://get-bb.github.io/reports/issues/4675/repro/regression.patch -o /tmp/issue-4675-regression.patch
git apply /tmp/issue-4675-regression.patch
pnpm exec turbo run test --filter=bb-plugin-tasks -- api/api.test.ts -t 'returns and publishes a persisted comment'

Expected: two comments are committed; the save response has returned with initial notifiedCount=0; comments:changed has published once before delivery is released. After release, the persisted count becomes 1 and a second event publishes. Actual on main: persistence succeeds, but responseReturned remains false. Both cases fail before reaching the initial-event assertion.

bb-plugin-tasks:test: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
bb-plugin-tasks:test: 
bb-plugin-tasks:test:  FAIL  |bb-plugin-tasks| api/api.test.ts > Tasks RPC domain API > returns and publishes a persisted comment while notification get is blocked
bb-plugin-tasks:test:  FAIL  |bb-plugin-tasks| api/api.test.ts > Tasks RPC domain API > returns and publishes a persisted comment while notification send is blocked
bb-plugin-tasks:test: AssertionError: expected false to be true // Object.is equality
bb-plugin-tasks:test: 
bb-plugin-tasks:test: - Expected
bb-plugin-tasks:test: + Received
bb-plugin-tasks:test: 
bb-plugin-tasks:test: - true
bb-plugin-tasks:test: + false
bb-plugin-tasks:test: 
bb-plugin-tasks:test:  ❯ api/api.test.ts:67:34
bb-plugin-tasks:test:      65|         await new Promise<void>((resolve) => setImmediate(resolve));
bb-plugin-tasks:test:      66|         expect(store.tasks.listComments(task.id)).toHaveLength(2);
bb-plugin-tasks:test:      67|         expect(responseReturned).toBe(true);
bb-plugin-tasks:test:        |                                  ^
bb-plugin-tasks:test:      68|         const { comment } = await response;
bb-plugin-tasks:test:      69|         expect(comment.notifiedCount).toBe(0);
bb-plugin-tasks:test: 
bb-plugin-tasks:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯
bb-plugin-tasks:test: 
bb-plugin-tasks:test: 
bb-plugin-tasks:test:  Test Files  1 failed (1)
bb-plugin-tasks:test:       Tests  2 failed | 22 skipped (24)

Exact test-only patch used in both clean checkouts · Complete baseline test file with regression added

Regression test excerpt (imports and the surrounding existing tests are in the complete artifact):

  it.each(["get", "send"])(
    "returns and publishes a persisted comment while notification %s is blocked",
    async (blockedOperation) => {
      let releaseDelivery = () => {};
      const deliveryGate = new Promise<void>((resolve) => {
        releaseDelivery = resolve;
      });
      const { bb, harness } = createFakePluginHost({
        pluginId: "tasks",
        sdk: {
          threads: {
            get: async ({ threadId }) => {
              if (blockedOperation === "get") await deliveryGate;
              return makeThreadResponse({ id: threadId, status: "active" });
            },
            send: async () => {
              if (blockedOperation === "send") await deliveryGate;
            },
          },
        },
      });
      const store = createStore(bb);
      registerTasksApi(bb, store);
      const project = store.tasks.createProject({
        name: "Delivery gate",
        prefix: "GATE",
        color: "blue",
      });
      const task = store.tasks.createTask({
        projectId: project.id,
        title: "Persist before delivery",
      });
      store.tasks.createComment({
        taskId: task.id,
        kind: "agent",
        authorName: "Worker",
        threadId: "thr_delivery_gate",
        body: "Prior reply",
      });
      let responseReturned = false;
      const response = harness
        .callRpc("createComment", {
          taskId: task.id,
          body: "New context",
          notify: true,
        })
        .then((result) => {
          responseReturned = true;
          return tasksRpcContract.createComment.output.parse(result);
        });
      try {
        await new Promise<void>((resolve) => setImmediate(resolve));
        expect(store.tasks.listComments(task.id)).toHaveLength(2);
        expect(responseReturned).toBe(true);
        const { comment } = await response;
        expect(comment.notifiedCount).toBe(0);
        expect(harness.realtimeSignals).toEqual([
          { channel: "comments:changed", payload: { taskId: task.id } },
        ]);
        releaseDelivery();
        await expect
          .poll(() => store.tasks.getComment(comment.id)?.notifiedCount)
          .toBe(1);
        expect(harness.realtimeSignals).toEqual([
          { channel: "comments:changed", payload: { taskId: task.id } },
          { channel: "comments:changed", payload: { taskId: task.id } },
        ]);
      } finally {
        releaseDelivery();
        await response;
        await harness.dispose();
      }
    },
  );

5. Root cause

The insert is a synchronous database transaction. The async helper then awaits external thread operations before it updates the count, publishes the comment event, and returns. Durable persistence and agent-side acknowledgement are therefore on the same response critical path. No rollback, SQLite lock, or data loss is needed for the reproduced stall.

Base createComment, lines 414–443

if (input.notify) {
  const notifiedCount = await deliverCommentToLatestAgent(bb, store.tasks, {
    taskId: comment.taskId,
    commentId: comment.id,
    body: comment.body,
    authorName: comment.authorName,
  });
  comment = store.transaction(() =>
    store.tasks.updateComment(comment.id, { notifiedCount }),
  );
}
publishCommentsChanged(bb, input.taskId);
return comment;

Delivery helper, lines 25–56 awaits threads.get then threads.send with steer-if-active, catches transport errors, logs them, and returns zero on failure. The composer awaits this same RPC before clearing its input. The API regression establishes the blocking dependency; it does not measure real agent-turn cost.

6. Proposed fix and tested implementation

Publish the initial change immediately after committing the insert. Let the app/SDK RPC return that saved comment without awaiting delivery. Keep an explicit internal awaitDelivery choice for CLI calls, preserving their final JSON count. Delivery persists its final count and publishes a second change; rejected transport still logs and leaves zero. Catch background update failures so they do not become unhandled promise rejections.

The verified candidate is confined to the Tasks subsystem: five files, 164 changed text lines (157 additions + 7 deletions), no dependencies, generated files, migrations, stored-data changes, public field or schema changes, or daemon wire changes. No public wait option is introduced; CLI flags remain unchanged.

7. Related work

No linked open pull request was present in the issue timeline or the open-PR search at investigation time. No related issue was required to explain this root cause. Comment-list thread/provider enrichment remains separate work.

8. Verification

The same agent repeated the exact test-only patch and filtered command in a second clean detached checkout of 049d6a932ebae9e9060eb9fdd08030f7cc597d28, with a fresh frozen install and new harness-created SQLite storage. Both blocked operations again failed with expected true / received false. This is a second clean reproduction, not an independent-agent review. No correction to the causal finding was needed.

Before fix: two focused regressions failed in each checkout. After fix: the API and CLI suites passed all 52 tests; the complete Tasks suite passed 411 tests in 40 files. After updating the composer test caller for the required internal option, its focused suite passed 13 tests. Final Tasks lint and typecheck passed; lint emitted 31 existing warnings and zero errors. git diff --check passed. The failure test confirms saved body, notifiedCount=0, and warning logging when send rejects.

9. Appendix

Additional commands: pnpm exec turbo run lint typecheck test --filter=bb-plugin-tasks (all 411 tests passed; it exposed one missing internal argument in a test caller, subsequently fixed), pnpm exec turbo run test --filter=bb-plugin-tasks -- views/activity/task-activity.test.tsx, pnpm exec turbo run lint typecheck --filter=bb-plugin-tasks, pnpm exec oxfmt <changed Tasks paths>, git diff --check, and git diff --numstat origin/main. All source links use the pinned trusted commit. Issue claims were treated as untrusted evidence; no contributor patch, branch, script, or external issue link was executed.

> AGENT GENERATED