@@ -2191,30 +2191,94 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
21912191 } ) ;
21922192 } ) ;
21932193
2194+ // In-memory per session/poll-loop instance: a fresh bridge (and thus a
2195+ // fresh watermark and failure streak) starts on every run, which is fine.
2196+ // There is nothing to recover across restarts, since the whole point of
2197+ // the watermark is to dedupe within a single still-running poll loop.
2198+ let lastDeliveredEventName : string | null = null ;
2199+ let consecutivePollFailures = 0 ;
2200+
2201+ const logPollFailure = async ( message : string ) => {
2202+ consecutivePollFailures += 1 ;
2203+ await onLog (
2204+ "stderr" ,
2205+ `[paperclip] ACP process session bridge poll failed: ${ message } ` +
2206+ `(attempt ${ consecutivePollFailures } /${ MAX_CONSECUTIVE_POLL_FAILURES } )\n` ,
2207+ ) ;
2208+ // A single failed poll cycle (e.g. the directory listing itself
2209+ // failing, a single event file that will not read, or a malformed
2210+ // event body) is treated as transient: only tear the session down once
2211+ // MAX_CONSECUTIVE_POLL_FAILURES full cycles have failed back to back.
2212+ // Any cycle that completes without a failure resets the streak to 0.
2213+ if ( consecutivePollFailures >= MAX_CONSECUTIVE_POLL_FAILURES ) {
2214+ deliverRemoteEvent ( { type : "error" , message } ) ;
2215+ return true ;
2216+ }
2217+ return false ;
2218+ } ;
2219+
21942220 const poll = async ( ) => {
21952221 if ( stopping ) return ;
21962222 try {
2197- // Read every file this tick fetched before this loop decides whether to
2198- // keep polling. A `shutdownAck` can land in the same batch right after
2199- // an `exit` event; deliver it too, so this tick never drops an
2200- // already-fetched (and already-removed-from-disk) event.
2201- const events = await readRemoteJsonFiles ( { client, dir : eventsDir } ) ;
2223+ const { events, stoppedEarly } = await readRemoteJsonFiles ( {
2224+ client,
2225+ dir : eventsDir ,
2226+ afterName : lastDeliveredEventName ,
2227+ } ) ;
2228+ let midBatchFailure : string | null = null ;
22022229 for ( const event of events ) {
2203- const parsed = JSON . parse ( event . body ) as {
2230+ let parsed : {
22042231 type ?: string ;
22052232 stream ?: "stdout" | "stderr" ;
22062233 data ?: string ;
22072234 code ?: number | null ;
22082235 signal ?: string | null ;
22092236 message ?: string ;
22102237 } ;
2211- deliverRemoteEvent ( parsed ) ;
2238+ try {
2239+ parsed = JSON . parse ( event . body ) as typeof parsed ;
2240+ } catch ( error ) {
2241+ const message = error instanceof Error ? error . message : String ( error ) ;
2242+ midBatchFailure = `failed to parse ACP process session event file ${ event . name } : ${ message } ` ;
2243+ break ;
2244+ }
2245+ try {
2246+ deliverRemoteEvent ( parsed ) ;
2247+ } catch ( error ) {
2248+ const message = error instanceof Error ? error . message : String ( error ) ;
2249+ midBatchFailure = `failed to deliver ACP process session event file ${ event . name } : ${ message } ` ;
2250+ break ;
2251+ }
2252+ // Only now that the event has actually been handed to the caller do
2253+ // we advance the watermark and attempt to remove the remote file. A
2254+ // throw above (parse or deliver) leaves both untouched, so the file
2255+ // is re-read from exactly this point on the next cycle and nothing
2256+ // already delivered is ever repeated.
2257+ lastDeliveredEventName = event . name ;
2258+ const filePath = path . posix . join ( eventsDir , event . name ) ;
2259+ try {
2260+ await client . remove ( filePath ) ;
2261+ } catch ( removeError ) {
2262+ const removeMessage = removeError instanceof Error ? removeError . message : String ( removeError ) ;
2263+ await onLog (
2264+ "stderr" ,
2265+ `[paperclip] ACP process session bridge failed to remove processed event file ${ event . name } ; ` +
2266+ `relying on the delivery watermark to avoid re-sending it: ${ removeMessage } \n` ,
2267+ ) ;
2268+ }
2269+ }
2270+ if ( midBatchFailure ) {
2271+ if ( await logPollFailure ( midBatchFailure ) ) return ;
2272+ } else if ( stoppedEarly ) {
2273+ const error = stoppedEarly . error ;
2274+ const message = error instanceof Error ? error . message : String ( error ) ;
2275+ if ( await logPollFailure ( `failed to read ACP process session event file ${ stoppedEarly . name } : ${ message } ` ) ) return ;
2276+ } else {
2277+ consecutivePollFailures = 0 ;
22122278 }
22132279 } catch ( error ) {
22142280 const message = error instanceof Error ? error . message : String ( error ) ;
2215- await onLog ( "stderr" , `[paperclip] ACP process session bridge poll failed: ${ message } \n` ) ;
2216- deliverRemoteEvent ( { type : "error" , message } ) ;
2217- return ;
2281+ if ( await logPollFailure ( message ) ) return ;
22182282 } finally {
22192283 if ( ! stopping ) {
22202284 schedulePoll ( ) ;
@@ -2375,7 +2439,7 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
23752439 if ( stopReadingForShutdownAck ) return ;
23762440 void ( async ( ) => {
23772441 try {
2378- const events = await readRemoteJsonFiles ( { client, dir : eventsDir } ) ;
2442+ const { events } = await readRemoteJsonFiles ( { client, dir : eventsDir , afterName : null } ) ;
23792443 if ( stopReadingForShutdownAck ) return ;
23802444 for ( const event of events ) {
23812445 try {
@@ -4470,11 +4534,39 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
44704534 if ( method !== "GET" && method !== "HEAD" && typeof request . body === "string" ) {
44714535 forwardInit . body = request . body ;
44724536 }
4473- const response = await fetch ( buildBridgeForwardUrl ( hostApiUrl , request ) , forwardInit ) ;
4537+ let response : Response ;
4538+ try {
4539+ response = await fetch ( buildBridgeForwardUrl ( hostApiUrl , request ) , forwardInit ) ;
4540+ } catch ( error ) {
4541+ // Map fetch failures to a faithful status the in-sandbox agent can act
4542+ // on, instead of letting them surface as an opaque generic 502. A
4543+ // timeout in particular is ambiguous on a mutating call ("did my write
4544+ // land?"), so it must be distinguishable -- otherwise the agent tends to
4545+ // confabulate an outcome.
4546+ const name = error instanceof Error ? error . name : "" ;
4547+ if ( name === "TimeoutError" || name === "AbortError" ) {
4548+ return {
4549+ status : 504 ,
4550+ headers : { "content-type" : "application/json" } ,
4551+ body : JSON . stringify ( {
4552+ error : "The Paperclip API did not respond within the bridge timeout. The request may or may not have been applied; re-read state before retrying." ,
4553+ code : "bridge_upstream_timeout" ,
4554+ } ) ,
4555+ } ;
4556+ }
4557+ return {
4558+ status : 502 ,
4559+ headers : { "content-type" : "application/json" } ,
4560+ body : JSON . stringify ( {
4561+ error : `Bridge could not reach the Paperclip API: ${ error instanceof Error ? error . message : String ( error ) } ` ,
4562+ code : "bridge_upstream_unreachable" ,
4563+ } ) ,
4564+ } ;
4565+ }
44744566 if ( emitDebugLog ) {
44754567 await onLog (
44764568 "stdout" ,
4477- `[paperclip] Bridge proxy response ${ response . status } for ${ method } ${ request . path } ${ request . query ? `?${ request . query } ` : "" } \n` ,
4569+ `[paperclip] Bridge proxy response ${ response . status } for ${ method } ${ request . path } ${ request . query ? `?${ request . query } ` : "" } (url= ${ response . url || "-" } ct= ${ response . headers . get ( "content-type" ) ?? "-" } server= ${ response . headers . get ( "server" ) ?? "-" } xpb= ${ response . headers . get ( "x-powered-by" ) ?? "-" } redirected= ${ response . redirected } ) \n` ,
44784570 ) ;
44794571 }
44804572 // The host delivered response headers, so the response-body read starts after
0 commit comments