@@ -1499,6 +1499,322 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
14991499 } ) ,
15001500 ) ;
15011501
1502+ it . effect ( "clears stale pending approvals from projected shell summaries" , ( ) =>
1503+ Effect . gen ( function * ( ) {
1504+ const projectionPipeline = yield * OrchestrationProjectionPipeline ;
1505+ const eventStore = yield * OrchestrationEventStore ;
1506+ const sql = yield * SqlClient . SqlClient ;
1507+ const appendAndProject = ( event : Parameters < typeof eventStore . append > [ 0 ] ) =>
1508+ eventStore
1509+ . append ( event )
1510+ . pipe ( Effect . flatMap ( ( savedEvent ) => projectionPipeline . projectEvent ( savedEvent ) ) ) ;
1511+
1512+ yield * appendAndProject ( {
1513+ type : "project.created" ,
1514+ eventId : EventId . make ( "evt-stale-approval-1" ) ,
1515+ aggregateKind : "project" ,
1516+ aggregateId : ProjectId . make ( "project-stale-approval" ) ,
1517+ occurredAt : "2026-02-26T12:30:00.000Z" ,
1518+ commandId : CommandId . make ( "cmd-stale-approval-1" ) ,
1519+ causationEventId : null ,
1520+ correlationId : CorrelationId . make ( "cmd-stale-approval-1" ) ,
1521+ metadata : { } ,
1522+ payload : {
1523+ projectId : ProjectId . make ( "project-stale-approval" ) ,
1524+ title : "Project Stale Approval" ,
1525+ workspaceRoot : "/tmp/project-stale-approval" ,
1526+ defaultModelSelection : null ,
1527+ scripts : [ ] ,
1528+ createdAt : "2026-02-26T12:30:00.000Z" ,
1529+ updatedAt : "2026-02-26T12:30:00.000Z" ,
1530+ } ,
1531+ } ) ;
1532+
1533+ yield * appendAndProject ( {
1534+ type : "thread.created" ,
1535+ eventId : EventId . make ( "evt-stale-approval-2" ) ,
1536+ aggregateKind : "thread" ,
1537+ aggregateId : ThreadId . make ( "thread-stale-approval" ) ,
1538+ occurredAt : "2026-02-26T12:30:01.000Z" ,
1539+ commandId : CommandId . make ( "cmd-stale-approval-2" ) ,
1540+ causationEventId : null ,
1541+ correlationId : CorrelationId . make ( "cmd-stale-approval-2" ) ,
1542+ metadata : { } ,
1543+ payload : {
1544+ threadId : ThreadId . make ( "thread-stale-approval" ) ,
1545+ projectId : ProjectId . make ( "project-stale-approval" ) ,
1546+ title : "Thread Stale Approval" ,
1547+ modelSelection : {
1548+ provider : "codex" ,
1549+ model : "gpt-5-codex" ,
1550+ } ,
1551+ runtimeMode : "approval-required" ,
1552+ interactionMode : "default" ,
1553+ branch : null ,
1554+ worktreePath : null ,
1555+ createdAt : "2026-02-26T12:30:01.000Z" ,
1556+ updatedAt : "2026-02-26T12:30:01.000Z" ,
1557+ } ,
1558+ } ) ;
1559+
1560+ yield * appendAndProject ( {
1561+ type : "thread.activity-appended" ,
1562+ eventId : EventId . make ( "evt-stale-approval-3" ) ,
1563+ aggregateKind : "thread" ,
1564+ aggregateId : ThreadId . make ( "thread-stale-approval" ) ,
1565+ occurredAt : "2026-02-26T12:30:02.000Z" ,
1566+ commandId : CommandId . make ( "cmd-stale-approval-3" ) ,
1567+ causationEventId : null ,
1568+ correlationId : CorrelationId . make ( "cmd-stale-approval-3" ) ,
1569+ metadata : { } ,
1570+ payload : {
1571+ threadId : ThreadId . make ( "thread-stale-approval" ) ,
1572+ activity : {
1573+ id : EventId . make ( "activity-stale-approval-requested" ) ,
1574+ tone : "approval" ,
1575+ kind : "approval.requested" ,
1576+ summary : "Command approval requested" ,
1577+ payload : {
1578+ requestId : "approval-request-stale-1" ,
1579+ requestKind : "command" ,
1580+ } ,
1581+ turnId : null ,
1582+ createdAt : "2026-02-26T12:30:02.000Z" ,
1583+ } ,
1584+ } ,
1585+ } ) ;
1586+
1587+ yield * appendAndProject ( {
1588+ type : "thread.activity-appended" ,
1589+ eventId : EventId . make ( "evt-stale-approval-4" ) ,
1590+ aggregateKind : "thread" ,
1591+ aggregateId : ThreadId . make ( "thread-stale-approval" ) ,
1592+ occurredAt : "2026-02-26T12:30:03.000Z" ,
1593+ commandId : CommandId . make ( "cmd-stale-approval-4" ) ,
1594+ causationEventId : null ,
1595+ correlationId : CorrelationId . make ( "cmd-stale-approval-4" ) ,
1596+ metadata : { } ,
1597+ payload : {
1598+ threadId : ThreadId . make ( "thread-stale-approval" ) ,
1599+ activity : {
1600+ id : EventId . make ( "activity-stale-approval-failed" ) ,
1601+ tone : "error" ,
1602+ kind : "provider.approval.respond.failed" ,
1603+ summary : "Provider approval response failed" ,
1604+ payload : {
1605+ requestId : "approval-request-stale-1" ,
1606+ detail : "Unknown pending permission request: approval-request-stale-1" ,
1607+ } ,
1608+ turnId : null ,
1609+ createdAt : "2026-02-26T12:30:03.000Z" ,
1610+ } ,
1611+ } ,
1612+ } ) ;
1613+
1614+ const approvalRows = yield * sql < {
1615+ readonly requestId : string ;
1616+ readonly status : string ;
1617+ readonly resolvedAt : string | null ;
1618+ } > `
1619+ SELECT
1620+ request_id AS "requestId",
1621+ status,
1622+ resolved_at AS "resolvedAt"
1623+ FROM projection_pending_approvals
1624+ WHERE request_id = 'approval-request-stale-1'
1625+ ` ;
1626+ assert . deepEqual ( approvalRows , [
1627+ {
1628+ requestId : "approval-request-stale-1" ,
1629+ status : "resolved" ,
1630+ resolvedAt : "2026-02-26T12:30:03.000Z" ,
1631+ } ,
1632+ ] ) ;
1633+
1634+ // MarCode's lazy-hydration design (see FEATURES.md) computes
1635+ // `pendingApprovalCount` on read via SQL aggregate; the
1636+ // `projection_threads.pending_approval_count` column stays dormant
1637+ // (updated only by migration-time backfill), so upstream's runtime
1638+ // assertion on the denormalized column is skipped here.
1639+ } ) ,
1640+ ) ;
1641+
1642+ it . effect ( "ignores non-stale provider approval response failures" , ( ) =>
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-nonstale-approval-1" ) ,
1655+ aggregateKind : "project" ,
1656+ aggregateId : ProjectId . make ( "project-nonstale-approval" ) ,
1657+ occurredAt : "2026-02-26T12:45:00.000Z" ,
1658+ commandId : CommandId . make ( "cmd-nonstale-approval-1" ) ,
1659+ causationEventId : null ,
1660+ correlationId : CorrelationId . make ( "cmd-nonstale-approval-1" ) ,
1661+ metadata : { } ,
1662+ payload : {
1663+ projectId : ProjectId . make ( "project-nonstale-approval" ) ,
1664+ title : "Project Non-Stale Approval" ,
1665+ workspaceRoot : "/tmp/project-nonstale-approval" ,
1666+ defaultModelSelection : null ,
1667+ scripts : [ ] ,
1668+ createdAt : "2026-02-26T12:45:00.000Z" ,
1669+ updatedAt : "2026-02-26T12:45:00.000Z" ,
1670+ } ,
1671+ } ) ;
1672+
1673+ yield * appendAndProject ( {
1674+ type : "thread.created" ,
1675+ eventId : EventId . make ( "evt-nonstale-approval-2" ) ,
1676+ aggregateKind : "thread" ,
1677+ aggregateId : ThreadId . make ( "thread-nonstale-approval" ) ,
1678+ occurredAt : "2026-02-26T12:45:01.000Z" ,
1679+ commandId : CommandId . make ( "cmd-nonstale-approval-2" ) ,
1680+ causationEventId : null ,
1681+ correlationId : CorrelationId . make ( "cmd-nonstale-approval-2" ) ,
1682+ metadata : { } ,
1683+ payload : {
1684+ threadId : ThreadId . make ( "thread-nonstale-approval" ) ,
1685+ projectId : ProjectId . make ( "project-nonstale-approval" ) ,
1686+ title : "Thread Non-Stale 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-26T12:45:01.000Z" ,
1696+ updatedAt : "2026-02-26T12:45:01.000Z" ,
1697+ } ,
1698+ } ) ;
1699+
1700+ yield * appendAndProject ( {
1701+ type : "thread.activity-appended" ,
1702+ eventId : EventId . make ( "evt-nonstale-approval-3" ) ,
1703+ aggregateKind : "thread" ,
1704+ aggregateId : ThreadId . make ( "thread-nonstale-approval" ) ,
1705+ occurredAt : "2026-02-26T12:45:02.000Z" ,
1706+ commandId : CommandId . make ( "cmd-nonstale-approval-3" ) ,
1707+ causationEventId : null ,
1708+ correlationId : CorrelationId . make ( "cmd-nonstale-approval-3" ) ,
1709+ metadata : { } ,
1710+ payload : {
1711+ threadId : ThreadId . make ( "thread-nonstale-approval" ) ,
1712+ activity : {
1713+ id : EventId . make ( "activity-nonstale-approval-requested" ) ,
1714+ tone : "approval" ,
1715+ kind : "approval.requested" ,
1716+ summary : "Command approval requested" ,
1717+ payload : {
1718+ requestId : "approval-request-nonstale-existing" ,
1719+ requestKind : "command" ,
1720+ } ,
1721+ turnId : null ,
1722+ createdAt : "2026-02-26T12:45:02.000Z" ,
1723+ } ,
1724+ } ,
1725+ } ) ;
1726+
1727+ yield * appendAndProject ( {
1728+ type : "thread.activity-appended" ,
1729+ eventId : EventId . make ( "evt-nonstale-approval-4" ) ,
1730+ aggregateKind : "thread" ,
1731+ aggregateId : ThreadId . make ( "thread-nonstale-approval" ) ,
1732+ occurredAt : "2026-02-26T12:45:03.000Z" ,
1733+ commandId : CommandId . make ( "cmd-nonstale-approval-4" ) ,
1734+ causationEventId : null ,
1735+ correlationId : CorrelationId . make ( "cmd-nonstale-approval-4" ) ,
1736+ metadata : { } ,
1737+ payload : {
1738+ threadId : ThreadId . make ( "thread-nonstale-approval" ) ,
1739+ activity : {
1740+ id : EventId . make ( "activity-nonstale-approval-failed-existing" ) ,
1741+ tone : "error" ,
1742+ kind : "provider.approval.respond.failed" ,
1743+ summary : "Provider approval response failed" ,
1744+ payload : {
1745+ requestId : "approval-request-nonstale-existing" ,
1746+ detail : "Provider timed out while responding to approval request" ,
1747+ } ,
1748+ turnId : TurnId . make ( "turn-nonstale-failure" ) ,
1749+ createdAt : "2026-02-26T12:45:03.000Z" ,
1750+ } ,
1751+ } ,
1752+ } ) ;
1753+
1754+ yield * appendAndProject ( {
1755+ type : "thread.activity-appended" ,
1756+ eventId : EventId . make ( "evt-nonstale-approval-5" ) ,
1757+ aggregateKind : "thread" ,
1758+ aggregateId : ThreadId . make ( "thread-nonstale-approval" ) ,
1759+ occurredAt : "2026-02-26T12:45:04.000Z" ,
1760+ commandId : CommandId . make ( "cmd-nonstale-approval-5" ) ,
1761+ causationEventId : null ,
1762+ correlationId : CorrelationId . make ( "cmd-nonstale-approval-5" ) ,
1763+ metadata : { } ,
1764+ payload : {
1765+ threadId : ThreadId . make ( "thread-nonstale-approval" ) ,
1766+ activity : {
1767+ id : EventId . make ( "activity-nonstale-approval-failed-missing" ) ,
1768+ tone : "error" ,
1769+ kind : "provider.approval.respond.failed" ,
1770+ summary : "Provider approval response failed" ,
1771+ payload : {
1772+ requestId : "approval-request-nonstale-missing" ,
1773+ detail : "Provider timed out while responding to approval request" ,
1774+ } ,
1775+ turnId : null ,
1776+ createdAt : "2026-02-26T12:45:04.000Z" ,
1777+ } ,
1778+ } ,
1779+ } ) ;
1780+
1781+ const approvalRows = yield * sql < {
1782+ readonly requestId : string ;
1783+ readonly status : string ;
1784+ readonly turnId : string | null ;
1785+ readonly createdAt : string ;
1786+ readonly resolvedAt : string | null ;
1787+ } > `
1788+ SELECT
1789+ request_id AS "requestId",
1790+ status,
1791+ turn_id AS "turnId",
1792+ created_at AS "createdAt",
1793+ resolved_at AS "resolvedAt"
1794+ FROM projection_pending_approvals
1795+ WHERE request_id IN (
1796+ 'approval-request-nonstale-existing',
1797+ 'approval-request-nonstale-missing'
1798+ )
1799+ ORDER BY request_id
1800+ ` ;
1801+ assert . deepEqual ( approvalRows , [
1802+ {
1803+ requestId : "approval-request-nonstale-existing" ,
1804+ status : "pending" ,
1805+ turnId : null ,
1806+ createdAt : "2026-02-26T12:45:02.000Z" ,
1807+ resolvedAt : null ,
1808+ } ,
1809+ ] ) ;
1810+
1811+ // See note on the "clears stale pending approvals" test above: MarCode
1812+ // reads `pendingApprovalCount` via SQL aggregate rather than
1813+ // maintaining the denormalized `projection_threads.pending_approval_count`
1814+ // column at runtime, so upstream's runtime assertion is skipped.
1815+ } ) ,
1816+ ) ;
1817+
15021818 it . effect ( "does not fallback-retain messages whose turnId is removed by revert" , ( ) =>
15031819 Effect . gen ( function * ( ) {
15041820 const projectionPipeline = yield * OrchestrationProjectionPipeline ;
0 commit comments