#2691 · Task thread activity cannot recover from a failed turn
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
| Claim | Status | Evidence |
|---|---|---|
| A failed turn blocks later task thread status changes. | Verified | The test sent a failed event and then an active event. The stored state stayed failed. |
The plugin treats failed as a terminal thread state. | Verified | The 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 state | The 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 state | Both count paths accept only starting and working. |
| A later status check repairs the row. | Refuted | The reconcile loop excludes every state in the terminal set, including failed. |
3. Environment
- Trusted commit:
8d926c312569b63924825db761e9cc73663bc3bc. - Source package:
bb-plugin-tasks0.1.2. Product version: 0.40.0. - System: Linux 7.0.0-30-generic, x86-64.
- Node: 24.18.0. pnpm: 9.15.0. Vitest: 4.1.1.
- The test used the plugin test host and its temporary SQLite database.
- No server port, provider, account, or user data was necessary.
4. Minimal reproduction
- Check out the trusted commit.
- Run
pnpm install --frozen-lockfile --prefer-offline. - Run
pnpm exec turbo run build. - Save the test below as
plugins/tasks/lifecycle/recovery.repro.test.ts. - 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.
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'