#3614 · Child answers leave duplicate parent questions pending
REPRODUCED · Root-cause confidence: high
TL;DR
A parent and child can hold separate pending questions about the same decision. Answering the child resolves only the child's interaction; the parent question stays pending. The attention message encourages the parent to ask the user, but the resulting provider question has no explicit relationship to the child request. Two clean runs reproduced this server lifecycle failure without a real provider or user account.
Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| Attention guidance encourages asking the user | Verified | Template and existing notification test. |
| Child answer leaves duplicate parent pending | Verified | Both regression runs: child resolved, parent pending. |
| No child-unblocked taxonomy member | Verified | System-message enum at recorded commit. |
| A real provider opens the duplicate, and later completion cannot unblock it | Partly verified | Duplicate requests are created explicitly in the test; pending-interaction queue gating is verified by source inspection. No live model or UI timing test. |
| No agent can withdraw any interaction | Not established as a universal claim | Plugin cancellation paths exist. This report establishes absence of automatic cross-thread settlement for these provider requests. |
Environment
Public get-bb/bb main at 9e16411141788c67954bceb753bb64ac1dc7057f; Darwin 25.6.0 arm64; Node v22.22.3. Two detached trusted worktrees, each with frozen install and a successful 56-task Turbo build. Corepack supplied pnpm because the host pnpm launcher pointed at a missing installation. The server harness uses migrated SQLite and fresh temporary data directories, with in-process HTTP requests. No listening app server, provider session, or production data was used.
Minimal reproduction
- Save the inline regression patch below as regression.patch; it was authored from trusted repository tests.
- Run the following commands in a fresh checkout:
git clone https://github.com/get-bb/bb.git bb-3614 cd bb-3614 git checkout --detach 9e16411141788c67954bceb753bb64ac1dc7057f corepack pnpm install --frozen-lockfile --prefer-offline corepack pnpm exec turbo run build git apply /path/to/regression.patch corepack pnpm exec turbo run test --filter=@bb/server -- test/internal/internal-interactive-requests.test.ts -t 'issue 3614'
- The test creates a managed parent and child, registers a child user question, observes its parent attention command, registers the equivalent parent question, answers the child, and acknowledges the host resolve command.
Expected: the decision should no longer leave the parent blocked on a redundant pending question. Actual output, in both runs:
{"childStatus":"resolved","parentStatus":"pending"}
AssertionError: expected 'pending' not to be 'pending' // Object.is equality
Test Files 1 failed (1)
Tests 1 failed | 13 skipped (14)This assertion expresses the desired outcome, not a recommendation to cancel arbitrary parent questions. Full patch includes the required import and insertion into the existing harness file.
Complete regression patch
diff --git a/apps/server/test/internal/internal-interactive-requests.test.ts b/apps/server/test/internal/internal-interactive-requests.test.ts
index b035866b9..4346be0f4 100644
--- a/apps/server/test/internal/internal-interactive-requests.test.ts
+++ b/apps/server/test/internal/internal-interactive-requests.test.ts
@@ -23,6 +23,7 @@ import {
import {
createAllowForSessionResolution,
createAllowOnceResolution,
+ createUserAnswerResolution,
createCommandApprovalPayload,
createPermissionGrantApprovalPayload,
createUserQuestionPayload,
@@ -529,6 +530,83 @@ describe("internal interactive request lifecycle", () => {
});
});
+ it("issue 3614: resolving the child clears a duplicate parent question", async () => {
+ await withTestHarness(async (harness) => {
+ const { host, session } = seedHostSession(harness.deps, {
+ id: "host-child-needs-attention",
+ });
+ const { project } = seedProjectWithSource(harness.deps, {
+ hostId: host.id,
+ });
+ const parentEnvironment = seedEnvironment(harness.deps, {
+ hostId: host.id,
+ path: "/tmp/child-needs-attention-parent",
+ projectId: project.id,
+ });
+ const childEnvironment = seedEnvironment(harness.deps, {
+ hostId: host.id,
+ path: "/tmp/child-needs-attention-child",
+ projectId: project.id,
+ });
+ const parentThread = seedThread(harness.deps, {
+ environmentId: parentEnvironment.id,
+ projectId: project.id,
+ title: "Project coordinator",
+ });
+ seedThreadRuntimeState(harness.deps, {
+ environmentId: parentEnvironment.id,
+ inputText: "Initial parent task",
+ providerThreadId: "provider-parent-needs-attention",
+ threadId: parentThread.id,
+ });
+ const childThread = seedThread(harness.deps, {
+ environmentId: childEnvironment.id,
+ parentThreadId: parentThread.id,
+ projectId: project.id,
+ title: "Backend port validation cleanup",
+ });
+ const body = buildCommandApprovalInteractiveRequest({
+ sessionId: session.id,
+ suffix: "child-needs-attention",
+ threadId: childThread.id,
+ });
+
+ body.interaction.payload = createUserQuestionPayload();
+ const response = await registerInteractiveRequest({ body, harness });
+ expect(response.status).toBe(200);
+ await expect(readJson(response)).resolves.toMatchObject({
+ outcome: "created",
+ status: "pending",
+ });
+
+ const parentTurnCommand = await waitForQueuedCommand(
+ harness,
+ ({ command, row }) =>
+ row.state === "pending" &&
+ command.type === "turn.submit" &&
+ command.threadId === parentThread.id,
+ );
+ if (parentTurnCommand.command.type !== "turn.submit") {
+ throw new Error(
+ `Expected parent turn command, got ${parentTurnCommand.command.type}`,
+ );
+ }
+ const childId = await waitForPendingInteractionId({ harness, threadId: childThread.id });
+ const parentBody = buildCommandApprovalInteractiveRequest({ sessionId: session.id, suffix: "duplicate-parent", threadId: parentThread.id });
+ parentBody.interaction.payload = createUserQuestionPayload();
+ expect((await registerInteractiveRequest({ body: parentBody, harness })).status).toBe(200);
+ const parentId = await waitForPendingInteractionId({ harness, threadId: parentThread.id });
+ harness.deps.pendingInteractions.resolvePendingInteraction({ threadId: childThread.id, interactionId: childId, resolution: createUserAnswerResolution() });
+ const queuedResolve = await waitForQueuedCommand(harness, ({ command }) => command.type === "interactive.resolve" && command.interactionId === childId);
+ expect((await reportQueuedCommandSuccess(harness, queuedResolve, {})).status).toBe(200);
+ const child = harness.deps.pendingInteractions.getThreadInteraction({ threadId: childThread.id, interactionId: childId });
+ const parent = harness.deps.pendingInteractions.getThreadInteraction({ threadId: parentThread.id, interactionId: parentId });
+ console.log(JSON.stringify({ childStatus: child.status, parentStatus: parent.status }));
+ expect(child.status).toBe("resolved");
+ expect(parent.status).not.toBe("pending");
+ });
+ });
+
it("notifies a parent when a hidden delegated child needs attention", async () => {
await withTestHarness(async (harness) => {
const { host, session } = seedHostSession(harness.deps, {
Regression test body
it("issue 3614: resolving the child clears a duplicate parent question", async () => {
await withTestHarness(async (harness) => {
const { host, session } = seedHostSession(harness.deps, {
id: "host-child-needs-attention",
});
const { project } = seedProjectWithSource(harness.deps, {
hostId: host.id,
});
const parentEnvironment = seedEnvironment(harness.deps, {
hostId: host.id,
path: "/tmp/child-needs-attention-parent",
projectId: project.id,
});
const childEnvironment = seedEnvironment(harness.deps, {
hostId: host.id,
path: "/tmp/child-needs-attention-child",
projectId: project.id,
});
const parentThread = seedThread(harness.deps, {
environmentId: parentEnvironment.id,
projectId: project.id,
title: "Project coordinator",
});
seedThreadRuntimeState(harness.deps, {
environmentId: parentEnvironment.id,
inputText: "Initial parent task",
providerThreadId: "provider-parent-needs-attention",
threadId: parentThread.id,
});
const childThread = seedThread(harness.deps, {
environmentId: childEnvironment.id,
parentThreadId: parentThread.id,
projectId: project.id,
title: "Backend port validation cleanup",
});
const body = buildCommandApprovalInteractiveRequest({
sessionId: session.id,
suffix: "child-needs-attention",
threadId: childThread.id,
});
body.interaction.payload = createUserQuestionPayload();
const response = await registerInteractiveRequest({ body, harness });
expect(response.status).toBe(200);
await expect(readJson(response)).resolves.toMatchObject({
outcome: "created",
status: "pending",
});
const parentTurnCommand = await waitForQueuedCommand(
harness,
({ command, row }) =>
row.state === "pending" &&
command.type === "turn.submit" &&
command.threadId === parentThread.id,
);
if (parentTurnCommand.command.type !== "turn.submit") {
throw new Error(
`Expected parent turn command, got ${parentTurnCommand.command.type}`,
);
}
const childId = await waitForPendingInteractionId({ harness, threadId: childThread.id });
const parentBody = buildCommandApprovalInteractiveRequest({ sessionId: session.id, suffix: "duplicate-parent", threadId: parentThread.id });
parentBody.interaction.payload = createUserQuestionPayload();
expect((await registerInteractiveRequest({ body: parentBody, harness })).status).toBe(200);
const parentId = await waitForPendingInteractionId({ harness, threadId: parentThread.id });
harness.deps.pendingInteractions.resolvePendingInteraction({ threadId: childThread.id, interactionId: childId, resolution: createUserAnswerResolution() });
const queuedResolve = await waitForQueuedCommand(harness, ({ command }) => command.type === "interactive.resolve" && command.interactionId === childId);
expect((await reportQueuedCommandSuccess(harness, queuedResolve, {})).status).toBe(200);
const child = harness.deps.pendingInteractions.getThreadInteraction({ threadId: childThread.id, interactionId: childId });
const parent = harness.deps.pendingInteractions.getThreadInteraction({ threadId: parentThread.id, interactionId: parentId });
console.log(JSON.stringify({ childStatus: child.status, parentStatus: parent.status }));
expect(child.status).toBe("resolved");
expect(parent.status).not.toBe("pending");
});
});
Root cause
Attention guidance asks the parent to obtain a missing decision. The notification builder names the child but does not attach an interaction relationship to a later provider question.
resolvePendingInteraction selects an interaction by ID and validates its own thread ID. Settlement notifies only that interaction’s thread. The settled listener requests a queue dispatch for the same thread. Interaction-settled dispatch returns while that thread still has a pending interaction. The child's resolved row therefore provides no way to identify or clear the parent's independently created question. The taxonomy has attention and terminal child outcomes but no unblocked variant.
Proposed fix
Choose an explicit policy for handling questions delegated to children: avoid duplicate prompts, or associate a parent decision request with the original child interaction and define safe cancellation behavior. A wording change can reduce duplicates but cannot clear the state reproduced here. A new informational message alone also cannot clear an already pending provider interaction. Do not cancel every parent question merely because a child was answered.
No PR was opened: a complete fix needs a product/contract decision about related interactions and cancellation, outside the simple-fix rule. No production code was changed or pushed.
Verification
The same agent repeated the reproduction in a second clean detached checkout at the identical SHA, after its own frozen install and successful build. The identical test patch failed at the parent-status assertion again; the child-status assertion passed. Each harness run created a fresh temporary data directory and closed its resources. No ports were opened. No report correction was needed; the scope remains server lifecycle, not live provider behavior.
First run: 1 failed | 13 skipped; child resolved, parent pending. Second clean run: 1 failed | 13 skipped; same assertion. Existing lifecycle suite: 13 passed | 1 skipped.
Raw logs remain in local evidence storage. The complete patch and decisive output are inline above.
Related issues and PRs
Issue timeline metadata and an open-PR search found no linked open PR. Nearby issue #3020 concerns provider subagent visibility rather than this lifecycle failure; it is not established as a duplicate.
Appendix
The 13 existing lifecycle tests passed (the added regression was excluded). Existing-suite command: corepack pnpm exec turbo run test --filter=@bb/server -- test/internal/internal-interactive-requests.test.ts -t '^(?!.*issue 3614)'. The report patch changes tests only. git diff --check passed. Log paths are normalized for publication. Issue prose and suggestions were treated as untrusted claims; no issue-supplied code, URL, or instructions were executed.
AGENT GENERATED