Skip to content

Commit b6bcd14

Browse files
Backfill shell summary and clear stale approval failures
- Recompute projected thread shell counts during migration - Treat stale pending approval failures as resolved in projection - Add coverage for the backfill and projection behavior
1 parent 6f69934 commit b6bcd14

5 files changed

Lines changed: 704 additions & 0 deletions

File tree

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

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1505,6 +1505,149 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
15051505
}),
15061506
);
15071507

1508+
it.effect("clears stale pending approvals from projected shell summaries", () =>
1509+
Effect.gen(function* () {
1510+
const projectionPipeline = yield* OrchestrationProjectionPipeline;
1511+
const eventStore = yield* OrchestrationEventStore;
1512+
const sql = yield* SqlClient.SqlClient;
1513+
const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
1514+
eventStore
1515+
.append(event)
1516+
.pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
1517+
1518+
yield* appendAndProject({
1519+
type: "project.created",
1520+
eventId: EventId.make("evt-stale-approval-1"),
1521+
aggregateKind: "project",
1522+
aggregateId: ProjectId.make("project-stale-approval"),
1523+
occurredAt: "2026-02-26T12:30:00.000Z",
1524+
commandId: CommandId.make("cmd-stale-approval-1"),
1525+
causationEventId: null,
1526+
correlationId: CorrelationId.make("cmd-stale-approval-1"),
1527+
metadata: {},
1528+
payload: {
1529+
projectId: ProjectId.make("project-stale-approval"),
1530+
title: "Project Stale Approval",
1531+
workspaceRoot: "/tmp/project-stale-approval",
1532+
defaultModelSelection: null,
1533+
scripts: [],
1534+
createdAt: "2026-02-26T12:30:00.000Z",
1535+
updatedAt: "2026-02-26T12:30:00.000Z",
1536+
},
1537+
});
1538+
1539+
yield* appendAndProject({
1540+
type: "thread.created",
1541+
eventId: EventId.make("evt-stale-approval-2"),
1542+
aggregateKind: "thread",
1543+
aggregateId: ThreadId.make("thread-stale-approval"),
1544+
occurredAt: "2026-02-26T12:30:01.000Z",
1545+
commandId: CommandId.make("cmd-stale-approval-2"),
1546+
causationEventId: null,
1547+
correlationId: CorrelationId.make("cmd-stale-approval-2"),
1548+
metadata: {},
1549+
payload: {
1550+
threadId: ThreadId.make("thread-stale-approval"),
1551+
projectId: ProjectId.make("project-stale-approval"),
1552+
title: "Thread Stale Approval",
1553+
modelSelection: {
1554+
provider: "codex",
1555+
model: "gpt-5-codex",
1556+
},
1557+
runtimeMode: "approval-required",
1558+
interactionMode: "default",
1559+
branch: null,
1560+
worktreePath: null,
1561+
createdAt: "2026-02-26T12:30:01.000Z",
1562+
updatedAt: "2026-02-26T12:30:01.000Z",
1563+
},
1564+
});
1565+
1566+
yield* appendAndProject({
1567+
type: "thread.activity-appended",
1568+
eventId: EventId.make("evt-stale-approval-3"),
1569+
aggregateKind: "thread",
1570+
aggregateId: ThreadId.make("thread-stale-approval"),
1571+
occurredAt: "2026-02-26T12:30:02.000Z",
1572+
commandId: CommandId.make("cmd-stale-approval-3"),
1573+
causationEventId: null,
1574+
correlationId: CorrelationId.make("cmd-stale-approval-3"),
1575+
metadata: {},
1576+
payload: {
1577+
threadId: ThreadId.make("thread-stale-approval"),
1578+
activity: {
1579+
id: EventId.make("activity-stale-approval-requested"),
1580+
tone: "approval",
1581+
kind: "approval.requested",
1582+
summary: "Command approval requested",
1583+
payload: {
1584+
requestId: "approval-request-stale-1",
1585+
requestKind: "command",
1586+
},
1587+
turnId: null,
1588+
createdAt: "2026-02-26T12:30:02.000Z",
1589+
},
1590+
},
1591+
});
1592+
1593+
yield* appendAndProject({
1594+
type: "thread.activity-appended",
1595+
eventId: EventId.make("evt-stale-approval-4"),
1596+
aggregateKind: "thread",
1597+
aggregateId: ThreadId.make("thread-stale-approval"),
1598+
occurredAt: "2026-02-26T12:30:03.000Z",
1599+
commandId: CommandId.make("cmd-stale-approval-4"),
1600+
causationEventId: null,
1601+
correlationId: CorrelationId.make("cmd-stale-approval-4"),
1602+
metadata: {},
1603+
payload: {
1604+
threadId: ThreadId.make("thread-stale-approval"),
1605+
activity: {
1606+
id: EventId.make("activity-stale-approval-failed"),
1607+
tone: "error",
1608+
kind: "provider.approval.respond.failed",
1609+
summary: "Provider approval response failed",
1610+
payload: {
1611+
requestId: "approval-request-stale-1",
1612+
detail: "Unknown pending permission request: approval-request-stale-1",
1613+
},
1614+
turnId: null,
1615+
createdAt: "2026-02-26T12:30:03.000Z",
1616+
},
1617+
},
1618+
});
1619+
1620+
const approvalRows = yield* sql<{
1621+
readonly requestId: string;
1622+
readonly status: string;
1623+
readonly resolvedAt: string | null;
1624+
}>`
1625+
SELECT
1626+
request_id AS "requestId",
1627+
status,
1628+
resolved_at AS "resolvedAt"
1629+
FROM projection_pending_approvals
1630+
WHERE request_id = 'approval-request-stale-1'
1631+
`;
1632+
assert.deepEqual(approvalRows, [
1633+
{
1634+
requestId: "approval-request-stale-1",
1635+
status: "resolved",
1636+
resolvedAt: "2026-02-26T12:30:03.000Z",
1637+
},
1638+
]);
1639+
1640+
const threadRows = yield* sql<{
1641+
readonly pendingApprovalCount: number;
1642+
}>`
1643+
SELECT pending_approval_count AS "pendingApprovalCount"
1644+
FROM projection_threads
1645+
WHERE thread_id = 'thread-stale-approval'
1646+
`;
1647+
assert.deepEqual(threadRows, [{ pendingApprovalCount: 0 }]);
1648+
}),
1649+
);
1650+
15081651
it.effect("does not fallback-retain messages whose turnId is removed by revert", () =>
15091652
Effect.gen(function* () {
15101653
const projectionPipeline = yield* OrchestrationProjectionPipeline;

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,17 @@ function extractActivityRequestId(payload: unknown): ApprovalRequestId | null {
9090
return typeof requestId === "string" ? ApprovalRequestId.make(requestId) : null;
9191
}
9292

93+
function isStalePendingApprovalFailureDetail(detail: string | null): boolean {
94+
if (detail === null) {
95+
return false;
96+
}
97+
return (
98+
detail.includes("stale pending approval request") ||
99+
detail.includes("unknown pending approval request") ||
100+
detail.includes("unknown pending permission request")
101+
);
102+
}
103+
93104
function derivePendingUserInputCountFromActivities(
94105
activities: ReadonlyArray<ProjectionThreadActivity>,
95106
): number {
@@ -1245,6 +1256,30 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
12451256
});
12461257
return;
12471258
}
1259+
if (event.payload.activity.kind === "provider.approval.respond.failed") {
1260+
const payload =
1261+
typeof event.payload.activity.payload === "object" &&
1262+
event.payload.activity.payload !== null
1263+
? (event.payload.activity.payload as Record<string, unknown>)
1264+
: null;
1265+
const detail =
1266+
typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null;
1267+
if (isStalePendingApprovalFailureDetail(detail)) {
1268+
if (Option.isNone(existingRow)) {
1269+
return;
1270+
}
1271+
yield* projectionPendingApprovalRepository.upsert({
1272+
requestId,
1273+
threadId: existingRow.value.threadId,
1274+
turnId: existingRow.value.turnId,
1275+
status: "resolved",
1276+
decision: null,
1277+
createdAt: existingRow.value.createdAt,
1278+
resolvedAt: event.payload.activity.createdAt,
1279+
});
1280+
return;
1281+
}
1282+
}
12481283
if (Option.isSome(existingRow) && existingRow.value.status === "resolved") {
12491284
return;
12501285
}

apps/server/src/persistence/Migrations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import Migration0020 from "./Migrations/020_AuthAccessManagement.ts";
3636
import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts";
3737
import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts";
3838
import Migration0023 from "./Migrations/023_ProjectionThreadShellSummary.ts";
39+
import Migration0024 from "./Migrations/024_BackfillProjectionThreadShellSummary.ts";
3940

4041
/**
4142
* Migration loader with all migrations defined inline.
@@ -71,6 +72,7 @@ export const migrationEntries = [
7172
[21, "AuthSessionClientMetadata", Migration0021],
7273
[22, "AuthSessionLastConnectedAt", Migration0022],
7374
[23, "ProjectionThreadShellSummary", Migration0023],
75+
[24, "BackfillProjectionThreadShellSummary", Migration0024],
7476
] as const;
7577

7678
export const makeMigrationLoader = (throughId?: number) =>

0 commit comments

Comments
 (0)