← reports

#2691 · Task thread activity cannot recover from a failed turn

Bug Medium Effort: Low plugins tasks open on GitHub 2026-08-29 · base 8d926c312569

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

The Tasks plugin records a failed turn as a permanent thread state.

A later active event reaches the plugin, but a guard rejects the status change.

The Active task view and agent count then omit the recovered thread.

Two clean tests at the trusted base commit produced the same failed assertion.

The issue content was untrusted data. This review did not run issue code, links, patches, or branches.

2. Claims vs findings

ClaimStatusEvidence
A failed turn blocks later task thread status changes.VerifiedThe test sent a failed event and then an active event. The stored state stayed failed.
The plugin treats failed as a terminal thread state.VerifiedThe terminal set contains failed. The transition guard checks the current state against that set.
The Active view omits the recovered thread.Verified by code and stateThe filter accepts only starting and working. The test leaves the row at failed.
The agent count becomes zero for the recovered thread.Verified by code and stateBoth count paths accept only starting and working.
A later status check repairs the row.RefutedThe reconcile loop excludes every state in the terminal set, including failed.

3. Environment

4. Minimal reproduction

  1. Check out the trusted commit.
  2. Run pnpm install --frozen-lockfile --prefer-offline.
  3. Run pnpm exec turbo run build.
  4. Save the test below as plugins/tasks/lifecycle/recovery.repro.test.ts.
  5. Run the focused test from plugins/tasks.
    pnpm exec vitest run lifecycle/recovery.repro.test.ts --config vitest.config.ts

Expected: The stored state changes to working after the active event.

Actual:

FAIL lifecycle/recovery.repro.test.ts > restores activity after a failed turn
AssertionError: expected 'failed' to be 'working'

Expected: "working"
Received: "failed"

Test Files  1 failed (1)
Tests       1 failed (1)
Reproduction test source
import {
  createFakePluginHost,
  makeThreadResponse,
} from "@get-bb/plugin-sdk/testing";
import { expect, it } from "vitest";
import { createStore } from "../api";
import { registerLifecycle } from ".";

it("restores activity after a failed turn", async () => {
  const { bb, harness } = createFakePluginHost({ pluginId: "tasks" });
  const store = createStore(bb);
  const project = store.tasks.createProject({
    name: "Recovery",
    prefix: "REC",
    color: "blue",
  });
  const task = store.tasks.createTask({
    projectId: project.id,
    title: "Recover a worker",
  });
  const tracked = store.tasks.upsertTaskThread({
    taskId: task.id,
    threadId: "thr_worker",
    presetName: "Default",
    title: "Worker",
    liveStatus: "working",
  });
  await registerLifecycle(bb, store);

  await harness.emitThreadEvent("thread.failed", {
    thread: makeThreadResponse({ id: "thr_worker", status: "error" }),
    error: "provider exited",
  });
  await harness.emitThreadEvent("thread.active", {
    thread: makeThreadResponse({ id: "thr_worker", status: "active" }),
  });

  expect(store.tasks.getTaskThread(tracked.id)?.liveStatus).toBe("working");
  await harness.dispose();
});

5. Verification

I created a second clean checkout at the same full commit.

I completed the frozen install and the full Turbo build there.

I ran the same test with a separate temporary SQLite database.

The second test received failed instead of working.

No report claim required a correction after the second test.

6. Root cause

The lifecycle code puts both completed and failed in one terminal set.

const TERMINAL_LIVE_STATUSES = new Set<TaskThreadLiveStatus>([
  "completed",
  "failed",
]);

See lifecycle/index.ts lines 6-9.

The event map converts a thread error to failed. Active and stop states map to working.

See lifecycle/index.ts lines 15-27.

The transition guard rejects all changes when the current state exists in the terminal set.

if (
  thread.liveStatus === liveStatus ||
  TERMINAL_LIVE_STATUSES.has(thread.liveStatus)
) {
  return;
}

See lifecycle/index.ts lines 56-67.

Thus, a later active event proposes working, but the guard rejects it.

The reconcile loop also excludes failed rows from future checks.

See lifecycle/index.ts lines 119-135.

The Active query accepts only active task states.

See store.ts lines 897-901.

The sidebar and CLI counts use the same active-state rule.

See api/index.ts lines 128-137 and cli/index.ts lines 1072-1078.

7. Proposed fix

Keep only completed in the terminal set.

This change lets later events and the reconcile loop update a failed row.

Add the focused recovery test to the existing lifecycle test file.

Keep the Active query and count rules. They become correct when the state can recover.

8. Related issues

Issue #1761 also concerns task thread attachments. It has a different verified cause.

9. Appendix

GitHub metadata showed no linked open pull request.

The full build passed in both clean checkouts.

git fetch origin main
git worktree add --detach <clean-path> 8d926c312569b63924825db761e9cc73663bc3bc
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
node scripts/ensure-native-modules.mjs
pnpm exec vitest run lifecycle/recovery.repro.test.ts --config vitest.config.ts
gh pr list --repo get-bb/bb --state open --search '2691 in:body'