Skip to content

Commit fd5ec16

Browse files
committed
fix(server): resolve orphaned pending approvals on new turn start
When a batch of parallel tool calls is permitted via acceptForSession on the first request, the other pending canUseTool callbacks in the Claude adapter get unblocked via the updated session permissions without emitting a per-request approval.resolved activity. The corresponding projection_pending_approvals rows therefore stay status='pending' indefinitely, even though the turn has long since completed. The sidebar then shows a ghost "Pending Approval" badge on a thread that has nothing left to approve; opening the thread shows no approval UI because the detail subscription correctly reports the approvals are not pending. Fix in the projection pipeline: when a thread.turn-start-requested event arrives (user has moved on to a new turn), sweep any status='pending' approvals for that thread and mark them resolved with decision=null. By the time the user starts a fresh turn, any approvals that were still pending from a previous turn are definitively abandoned — the provider will never ask for them again. Regression test: ProjectionPipeline.test.ts "resolves orphaned pending approvals from earlier turns when a new turn starts" verifies three orphaned approval.requested rows get status=resolved with resolvedAt=turn-start-requested.createdAt. Note: this is a projection-side safety net. The proper fix for the underlying bug is in the Claude adapter (when acceptForSession grants session-scoped permission, emit approval.resolved activities for the pending requests that the updated permissions implicitly covered). That is a separate change that touches provider code and is better filed as a dedicated upstream fix.
1 parent c220483 commit fd5ec16

2 files changed

Lines changed: 185 additions & 0 deletions

File tree

apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1639,6 +1639,162 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
16391639
}),
16401640
);
16411641

1642+
it.effect("resolves orphaned pending approvals from earlier turns when a new turn starts", () =>
1643+
Effect.gen(function* () {
1644+
const projectionPipeline = yield* OrchestrationProjectionPipeline;
1645+
const eventStore = yield* OrchestrationEventStore;
1646+
const sql = yield* SqlClient.SqlClient;
1647+
const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
1648+
eventStore
1649+
.append(event)
1650+
.pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
1651+
1652+
yield* appendAndProject({
1653+
type: "project.created",
1654+
eventId: EventId.make("evt-orphan-approval-1"),
1655+
aggregateKind: "project",
1656+
aggregateId: ProjectId.make("project-orphan-approval"),
1657+
occurredAt: "2026-02-26T13:00:00.000Z",
1658+
commandId: CommandId.make("cmd-orphan-approval-1"),
1659+
causationEventId: null,
1660+
correlationId: CorrelationId.make("cmd-orphan-approval-1"),
1661+
metadata: {},
1662+
payload: {
1663+
projectId: ProjectId.make("project-orphan-approval"),
1664+
title: "Project Orphan Approval",
1665+
workspaceRoot: "/tmp/project-orphan-approval",
1666+
defaultModelSelection: null,
1667+
scripts: [],
1668+
createdAt: "2026-02-26T13:00:00.000Z",
1669+
updatedAt: "2026-02-26T13:00:00.000Z",
1670+
},
1671+
});
1672+
1673+
yield* appendAndProject({
1674+
type: "thread.created",
1675+
eventId: EventId.make("evt-orphan-approval-2"),
1676+
aggregateKind: "thread",
1677+
aggregateId: ThreadId.make("thread-orphan-approval"),
1678+
occurredAt: "2026-02-26T13:00:01.000Z",
1679+
commandId: CommandId.make("cmd-orphan-approval-2"),
1680+
causationEventId: null,
1681+
correlationId: CorrelationId.make("cmd-orphan-approval-2"),
1682+
metadata: {},
1683+
payload: {
1684+
threadId: ThreadId.make("thread-orphan-approval"),
1685+
projectId: ProjectId.make("project-orphan-approval"),
1686+
title: "Thread Orphan Approval",
1687+
modelSelection: {
1688+
provider: "codex",
1689+
model: "gpt-5-codex",
1690+
},
1691+
runtimeMode: "approval-required",
1692+
interactionMode: "default",
1693+
branch: null,
1694+
worktreePath: null,
1695+
createdAt: "2026-02-26T13:00:01.000Z",
1696+
updatedAt: "2026-02-26T13:00:01.000Z",
1697+
},
1698+
});
1699+
1700+
// Batch of parallel approval.requested activities tied to the first
1701+
// turn that will never receive per-request approval.resolved events
1702+
// (simulating the acceptForSession scenario).
1703+
for (const [index, requestId] of ["orphan-req-1", "orphan-req-2", "orphan-req-3"].entries()) {
1704+
yield* appendAndProject({
1705+
type: "thread.activity-appended",
1706+
eventId: EventId.make(`evt-orphan-approval-3-${index}`),
1707+
aggregateKind: "thread",
1708+
aggregateId: ThreadId.make("thread-orphan-approval"),
1709+
occurredAt: `2026-02-26T13:00:0${2 + index}.000Z`,
1710+
commandId: CommandId.make(`cmd-orphan-approval-3-${index}`),
1711+
causationEventId: null,
1712+
correlationId: CorrelationId.make(`cmd-orphan-approval-3-${index}`),
1713+
metadata: {},
1714+
payload: {
1715+
threadId: ThreadId.make("thread-orphan-approval"),
1716+
activity: {
1717+
id: EventId.make(`activity-orphan-approval-${index}`),
1718+
tone: "approval",
1719+
kind: "approval.requested",
1720+
summary: "Command approval requested",
1721+
payload: {
1722+
requestId,
1723+
requestKind: "command",
1724+
},
1725+
turnId: TurnId.make("orphan-turn-1"),
1726+
createdAt: `2026-02-26T13:00:0${2 + index}.000Z`,
1727+
},
1728+
},
1729+
});
1730+
}
1731+
1732+
const before = yield* sql<{ readonly count: number }>`
1733+
SELECT COUNT(*) AS count
1734+
FROM projection_pending_approvals
1735+
WHERE thread_id = 'thread-orphan-approval'
1736+
AND status = 'pending'
1737+
`;
1738+
assert.deepEqual(before, [{ count: 3 }]);
1739+
1740+
// User submits a new turn — should sweep the 3 orphaned rows.
1741+
yield* appendAndProject({
1742+
type: "thread.turn-start-requested",
1743+
eventId: EventId.make("evt-orphan-approval-4"),
1744+
aggregateKind: "thread",
1745+
aggregateId: ThreadId.make("thread-orphan-approval"),
1746+
occurredAt: "2026-02-26T13:00:10.000Z",
1747+
commandId: CommandId.make("cmd-orphan-approval-4"),
1748+
causationEventId: null,
1749+
correlationId: CorrelationId.make("cmd-orphan-approval-4"),
1750+
metadata: {},
1751+
payload: {
1752+
threadId: ThreadId.make("thread-orphan-approval"),
1753+
messageId: MessageId.make("orphan-message-1"),
1754+
runtimeMode: "approval-required",
1755+
interactionMode: "default",
1756+
createdAt: "2026-02-26T13:00:10.000Z",
1757+
},
1758+
});
1759+
1760+
const after = yield* sql<{
1761+
readonly requestId: string;
1762+
readonly status: string;
1763+
readonly resolvedAt: string | null;
1764+
readonly decision: string | null;
1765+
}>`
1766+
SELECT
1767+
request_id AS "requestId",
1768+
status,
1769+
resolved_at AS "resolvedAt",
1770+
decision
1771+
FROM projection_pending_approvals
1772+
WHERE thread_id = 'thread-orphan-approval'
1773+
ORDER BY created_at ASC
1774+
`;
1775+
assert.deepEqual(after, [
1776+
{
1777+
requestId: "orphan-req-1",
1778+
status: "resolved",
1779+
resolvedAt: "2026-02-26T13:00:10.000Z",
1780+
decision: null,
1781+
},
1782+
{
1783+
requestId: "orphan-req-2",
1784+
status: "resolved",
1785+
resolvedAt: "2026-02-26T13:00:10.000Z",
1786+
decision: null,
1787+
},
1788+
{
1789+
requestId: "orphan-req-3",
1790+
status: "resolved",
1791+
resolvedAt: "2026-02-26T13:00:10.000Z",
1792+
decision: null,
1793+
},
1794+
]);
1795+
}),
1796+
);
1797+
16421798
it.effect("ignores non-stale provider approval response failures", () =>
16431799
Effect.gen(function* () {
16441800
const projectionPipeline = yield* OrchestrationProjectionPipeline;

apps/server/src/orchestration/Layers/ProjectionPipeline.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1337,6 +1337,35 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
13371337
return;
13381338
}
13391339

1340+
case "thread.turn-start-requested": {
1341+
// Resolve orphaned pending approvals from earlier turns of this
1342+
// thread. Once the user submits a new turn, any approvals that were
1343+
// still pending from a previous turn are abandoned — the provider
1344+
// will never ask for them again. This most commonly happens when a
1345+
// batch of parallel tool calls is permitted via acceptForSession on
1346+
// the first request: the other pending canUseTool callbacks get
1347+
// unblocked via the updated session permissions without emitting a
1348+
// per-request approval.resolved activity, so the projection rows
1349+
// stay "pending" indefinitely. Without this sweep the sidebar shows
1350+
// a ghost "Pending Approval" badge on a thread that has nothing
1351+
// left to approve.
1352+
const rows = yield* projectionPendingApprovalRepository.listByThreadId({
1353+
threadId: event.payload.threadId,
1354+
});
1355+
for (const row of rows) {
1356+
if (row.status !== "pending") {
1357+
continue;
1358+
}
1359+
yield* projectionPendingApprovalRepository.upsert({
1360+
...row,
1361+
status: "resolved",
1362+
decision: null,
1363+
resolvedAt: event.payload.createdAt,
1364+
});
1365+
}
1366+
return;
1367+
}
1368+
13401369
default:
13411370
return;
13421371
}

0 commit comments

Comments
 (0)