@@ -153,6 +153,20 @@ type HarnessSession = {
153153 > ;
154154 alarm : ( ) => Promise < void > ;
155155 ctx : MemoryStorage ;
156+ currentSessionEpoch : ( ) => Promise < number > ;
157+ getStaleEpochStreamRequestIds : ( ) => Promise <
158+ ReadonlyArray < {
159+ readonly streamId : string ;
160+ readonly requestIds : ReadonlyArray < string | number > ;
161+ readonly epoch : number ;
162+ readonly currentEpoch : number ;
163+ } >
164+ > ;
165+ getStreamRequestIds : ( streamId : string ) => Promise < ReadonlyArray < string | number > | undefined > ;
166+ setStreamRequestIds : (
167+ streamId : string ,
168+ requestIds : ReadonlyArray < string | number > ,
169+ ) => Promise < void > ;
156170 dbHandle : { readonly end : ( ) => void } | null ;
157171 engine : ExecutionEngine < Cause . YieldableError > | null ;
158172 getConnections ?: ( ) => Iterable < unknown > ;
@@ -266,7 +280,14 @@ const approval = {
266280 content : { approved : true } ,
267281} satisfies ResumeResponse ;
268282
269- const makeHarnessSession = async ( ) : Promise < HarnessSession > => {
283+ /**
284+ * `storage` is a parameter so a test can build a SECOND session on the same
285+ * durable storage — that is exactly what a Durable Object reset looks like from
286+ * storage's point of view: same keys, brand new instance.
287+ */
288+ const makeHarnessSession = async (
289+ storage : MemoryStorage = new MemoryStorage ( ) ,
290+ ) : Promise < HarnessSession > => {
270291 const sessionId = "session-reconnect" ;
271292 const sessionMeta : SessionMeta = {
272293 organizationId : "org-1" ,
@@ -275,7 +296,6 @@ const makeHarnessSession = async (): Promise<HarnessSession> => {
275296 userId : "user-1" ,
276297 resource : defaultMcpResource ,
277298 } ;
278- const storage = new MemoryStorage ( ) ;
279299 const server = makeServer ( ) ;
280300 await server . connect ( new StaleCloseTransport ( ) ) ;
281301
@@ -1766,3 +1786,110 @@ describe("McpAgentSessionDOBase residency cap eviction", () => {
17661786 } ) ;
17671787 } ) ;
17681788} ) ;
1789+
1790+ // The request-id ledger (`__mcp_stream_reqs__:<streamId>`, written by the
1791+ // patched McpAgent — see patches/agents@0.17.3.patch) is the only durable
1792+ // record that a POST is still owed a response. A row exists from the moment the
1793+ // request is accepted until its final response is written, so a row that
1794+ // outlives the incarnation which accepted it is a request nothing will ever
1795+ // answer: the isolate was reset mid-execute. Each row carries the epoch of the
1796+ // incarnation that wrote it, and that is what separates "stranded" from
1797+ // "legitimately still running" — a browser-approval pause holds a row open for
1798+ // minutes inside ONE incarnation and must never be swept.
1799+ describe ( "McpAgentSessionDOBase stranded-request ledger" , ( ) => {
1800+ const ledgerKey = ( streamId : string ) => `__mcp_stream_reqs__:${ streamId } ` ;
1801+
1802+ it ( "stamps the accepting incarnation on every ledger row" , async ( ) => {
1803+ const session = await makeHarnessSession ( ) ;
1804+
1805+ await session . setStreamRequestIds ( "stream-a" , [ 1 , "two" ] ) ;
1806+
1807+ expect ( await session . ctx . storage . get ( ledgerKey ( "stream-a" ) ) ) . toEqual ( {
1808+ epoch : await session . currentSessionEpoch ( ) ,
1809+ requestIds : [ 1 , "two" ] ,
1810+ } ) ;
1811+ expect (
1812+ await session . getStreamRequestIds ( "stream-a" ) ,
1813+ "readers still see a plain request-id list" ,
1814+ ) . toEqual ( [ 1 , "two" ] ) ;
1815+ } ) ;
1816+
1817+ it ( "does not treat a row from the running incarnation as stranded" , async ( ) => {
1818+ const session = await makeHarnessSession ( ) ;
1819+
1820+ // What a browser-approval pause looks like: accepted, unanswered, and
1821+ // legitimately going to stay that way for minutes.
1822+ await session . setStreamRequestIds ( "stream-paused" , [ 9 ] ) ;
1823+
1824+ expect ( await session . getStaleEpochStreamRequestIds ( ) ) . toEqual ( [ ] ) ;
1825+ } ) ;
1826+
1827+ it ( "reports a row left by a previous incarnation as stranded" , async ( ) => {
1828+ const storage = new MemoryStorage ( ) ;
1829+ const beforeReset = await makeHarnessSession ( storage ) ;
1830+ await beforeReset . setStreamRequestIds ( "stream-lost" , [ 42 ] ) ;
1831+ await beforeReset . setStreamRequestIds ( "stream-also-lost" , [ "abc" ] ) ;
1832+
1833+ // The reset: same durable storage, a brand new Durable Object instance.
1834+ const afterReset = await makeHarnessSession ( storage ) ;
1835+ await afterReset . setStreamRequestIds ( "stream-live" , [ 100 ] ) ;
1836+
1837+ const stranded = await afterReset . getStaleEpochStreamRequestIds ( ) ;
1838+
1839+ // Order follows storage's key order, which this fake does not model, so
1840+ // the assertion is on the set.
1841+ expect (
1842+ [ ...stranded ] . sort ( ( a , b ) => a . streamId . localeCompare ( b . streamId ) ) ,
1843+ "only the rows the dead incarnation accepted" ,
1844+ ) . toMatchObject ( [
1845+ { streamId : "stream-also-lost" , requestIds : [ "abc" ] } ,
1846+ { streamId : "stream-lost" , requestIds : [ 42 ] } ,
1847+ ] ) ;
1848+ for ( const row of stranded ) expect ( row . epoch ) . toBeLessThan ( row . currentEpoch ) ;
1849+ } ) ;
1850+
1851+ it ( "reports a pre-epoch ledger row as stranded" , async ( ) => {
1852+ const session = await makeHarnessSession ( ) ;
1853+
1854+ // The shape rows had before they carried an epoch. One can only have been
1855+ // written by an earlier deployment, so it reads as epoch 0 and is swept.
1856+ await session . ctx . storage . put ( ledgerKey ( "stream-legacy" ) , [ 7 ] ) ;
1857+
1858+ expect ( await session . getStaleEpochStreamRequestIds ( ) ) . toEqual ( [
1859+ {
1860+ currentEpoch : await session . currentSessionEpoch ( ) ,
1861+ epoch : 0 ,
1862+ requestIds : [ 7 ] ,
1863+ streamId : "stream-legacy" ,
1864+ } ,
1865+ ] ) ;
1866+ expect (
1867+ await session . getStreamRequestIds ( "stream-legacy" ) ,
1868+ "and it is still readable as a request-id list" ,
1869+ ) . toEqual ( [ 7 ] ) ;
1870+ } ) ;
1871+
1872+ it ( "holds the idle lease for a request the running incarnation still owes" , async ( ) => {
1873+ const session = await makeHarnessSession ( ) ;
1874+ await session . setStreamRequestIds ( "stream-live" , [ 1 ] ) ;
1875+
1876+ await session . alarm ( ) ;
1877+
1878+ expect ( session . initialized , "live work keeps the runtime resident" ) . toBe ( true ) ;
1879+ expect ( session . ctx . alarm , "and re-arms the lease" ) . toBeGreaterThan ( 0 ) ;
1880+ } ) ;
1881+
1882+ it ( "does not let a stranded row extend the idle lease" , async ( ) => {
1883+ const storage = new MemoryStorage ( ) ;
1884+ const beforeReset = await makeHarnessSession ( storage ) ;
1885+ await beforeReset . setStreamRequestIds ( "stream-lost" , [ 1 ] ) ;
1886+
1887+ const afterReset = await makeHarnessSession ( storage ) ;
1888+ await afterReset . alarm ( ) ;
1889+
1890+ expect (
1891+ afterReset . initialized ,
1892+ "a request nothing will ever answer is dead work, not running work" ,
1893+ ) . toBe ( false ) ;
1894+ } ) ;
1895+ } ) ;
0 commit comments