1+ import { resolveAgentTimeoutMs } from "../../agents/timeout.js" ;
12import type { OpenClawConfig } from "../../config/config.js" ;
23import { logVerbose } from "../../globals.js" ;
34import { normalizeAgentId } from "../../routing/session-key.js" ;
@@ -20,6 +21,7 @@ import type {
2021 AcpRuntime ,
2122 AcpRuntimeCapabilities ,
2223 AcpRuntimeHandle ,
24+ AcpRuntimeSessionMode ,
2325 AcpRuntimeStatus ,
2426} from "../runtime/types.js" ;
2527import { reconcileManagerRuntimeSessionIdentifiers } from "./manager.identity-reconcile.js" ;
@@ -70,6 +72,10 @@ import {
7072} from "./runtime-options.js" ;
7173import { SessionActorQueue } from "./session-actor-queue.js" ;
7274
75+ const ACP_TURN_TIMEOUT_GRACE_MS = 1_000 ;
76+ const ACP_TURN_TIMEOUT_CLEANUP_GRACE_MS = 2_000 ;
77+ const ACP_TURN_TIMEOUT_REASON = "turn-timeout" ;
78+
7379export class AcpSessionManager {
7480 private readonly actorQueue = new SessionActorQueue ( ) ;
7581 private readonly actorTailBySession = this . actorQueue . getTailMapForTesting ( ) ;
@@ -621,6 +627,7 @@ export class AcpSessionManager {
621627 let activeTurnStarted = false ;
622628 let sawTurnOutput = false ;
623629 let retryFreshHandle = false ;
630+ let skipPostTurnCleanup = false ;
624631 try {
625632 const ensured = await this . ensureRuntimeHandle ( {
626633 cfg : input . cfg ,
@@ -667,26 +674,58 @@ export class AcpSessionManager {
667674 input . signal && typeof AbortSignal . any === "function"
668675 ? AbortSignal . any ( [ input . signal , internalAbortController . signal ] )
669676 : internalAbortController . signal ;
670- for await ( const event of runtime . runTurn ( {
671- handle,
672- text : input . text ,
673- attachments : input . attachments ,
674- mode : input . mode ,
675- requestId : input . requestId ,
676- signal : combinedSignal ,
677- } ) ) {
678- if ( event . type === "error" ) {
679- streamError = new AcpRuntimeError (
680- normalizeAcpErrorCode ( event . code ) ,
681- event . message ?. trim ( ) || "ACP turn failed before completion." ,
682- ) ;
683- } else if ( event . type === "text_delta" || event . type === "tool_call" ) {
684- sawTurnOutput = true ;
677+ const eventGate = { open : true } ;
678+ const turnPromise = ( async ( ) => {
679+ for await ( const event of runtime . runTurn ( {
680+ handle,
681+ text : input . text ,
682+ attachments : input . attachments ,
683+ mode : input . mode ,
684+ requestId : input . requestId ,
685+ signal : combinedSignal ,
686+ } ) ) {
687+ if ( ! eventGate . open ) {
688+ continue ;
689+ }
690+ if ( event . type === "error" ) {
691+ streamError = new AcpRuntimeError (
692+ normalizeAcpErrorCode ( event . code ) ,
693+ event . message ?. trim ( ) || "ACP turn failed before completion." ,
694+ ) ;
695+ } else if ( event . type === "text_delta" || event . type === "tool_call" ) {
696+ sawTurnOutput = true ;
697+ }
698+ if ( input . onEvent ) {
699+ await input . onEvent ( event ) ;
700+ }
685701 }
686- if ( input . onEvent ) {
687- await input . onEvent ( event ) ;
702+ if ( eventGate . open && streamError ) {
703+ throw streamError ;
688704 }
689- }
705+ } ) ( ) ;
706+ const turnTimeoutMs = this . resolveTurnTimeoutMs ( {
707+ cfg : input . cfg ,
708+ meta,
709+ } ) ;
710+ const sessionMode = meta . mode ;
711+ await this . awaitTurnWithTimeout ( {
712+ sessionKey,
713+ turnPromise,
714+ timeoutMs : turnTimeoutMs + ACP_TURN_TIMEOUT_GRACE_MS ,
715+ timeoutLabelMs : turnTimeoutMs ,
716+ onTimeout : async ( ) => {
717+ eventGate . open = false ;
718+ skipPostTurnCleanup = true ;
719+ if ( ! activeTurn ) {
720+ return ;
721+ }
722+ await this . cleanupTimedOutTurn ( {
723+ sessionKey,
724+ activeTurn,
725+ mode : sessionMode ,
726+ } ) ;
727+ } ,
728+ } ) ;
690729 if ( streamError ) {
691730 throw streamError ;
692731 }
@@ -735,7 +774,14 @@ export class AcpSessionManager {
735774 if ( activeTurn && this . activeTurnBySession . get ( actorKey ) === activeTurn ) {
736775 this . activeTurnBySession . delete ( actorKey ) ;
737776 }
738- if ( ! retryFreshHandle && runtime && handle && meta && meta . mode !== "oneshot" ) {
777+ if (
778+ ! retryFreshHandle &&
779+ ! skipPostTurnCleanup &&
780+ runtime &&
781+ handle &&
782+ meta &&
783+ meta . mode !== "oneshot"
784+ ) {
739785 ( { handle } = await this . reconcileRuntimeSessionIdentifiers ( {
740786 cfg : input . cfg ,
741787 sessionKey,
@@ -745,7 +791,14 @@ export class AcpSessionManager {
745791 failOnStatusError : false ,
746792 } ) ) ;
747793 }
748- if ( ! retryFreshHandle && runtime && handle && meta && meta . mode === "oneshot" ) {
794+ if (
795+ ! retryFreshHandle &&
796+ ! skipPostTurnCleanup &&
797+ runtime &&
798+ handle &&
799+ meta &&
800+ meta . mode === "oneshot"
801+ ) {
749802 try {
750803 await runtime . close ( {
751804 handle,
@@ -767,6 +820,191 @@ export class AcpSessionManager {
767820 } ) ;
768821 }
769822
823+ private resolveTurnTimeoutMs ( params : { cfg : OpenClawConfig ; meta : SessionAcpMeta } ) : number {
824+ const runtimeTimeoutSeconds = resolveRuntimeOptionsFromMeta ( params . meta ) . timeoutSeconds ;
825+ if (
826+ typeof runtimeTimeoutSeconds === "number" &&
827+ Number . isFinite ( runtimeTimeoutSeconds ) &&
828+ runtimeTimeoutSeconds > 0
829+ ) {
830+ return Math . max ( 1_000 , Math . round ( runtimeTimeoutSeconds * 1_000 ) ) ;
831+ }
832+ return resolveAgentTimeoutMs ( {
833+ cfg : params . cfg ,
834+ minMs : 1_000 ,
835+ } ) ;
836+ }
837+
838+ private async awaitTurnWithTimeout < T > ( params : {
839+ sessionKey : string ;
840+ turnPromise : Promise < T > ;
841+ timeoutMs : number ;
842+ timeoutLabelMs : number ;
843+ onTimeout : ( ) => Promise < void > ;
844+ } ) : Promise < T > {
845+ const observedTurnPromise : Promise <
846+ | {
847+ kind : "value" ;
848+ value : T ;
849+ }
850+ | {
851+ kind : "error" ;
852+ error : unknown ;
853+ }
854+ > = params . turnPromise . then (
855+ ( value ) => ( {
856+ kind : "value" as const ,
857+ value,
858+ } ) ,
859+ ( error ) => ( {
860+ kind : "error" as const ,
861+ error,
862+ } ) ,
863+ ) ;
864+
865+ if ( params . timeoutMs <= 0 ) {
866+ const outcome = await observedTurnPromise ;
867+ if ( outcome . kind === "error" ) {
868+ throw outcome . error ;
869+ }
870+ return outcome . value ;
871+ }
872+
873+ const timeoutToken = Symbol ( "acp-turn-timeout" ) ;
874+ let timer : NodeJS . Timeout | undefined ;
875+ const timeoutPromise = new Promise < typeof timeoutToken > ( ( resolve ) => {
876+ timer = setTimeout ( ( ) => resolve ( timeoutToken ) , params . timeoutMs ) ;
877+ timer . unref ?.( ) ;
878+ } ) ;
879+
880+ try {
881+ const outcome = await Promise . race ( [ observedTurnPromise , timeoutPromise ] ) ;
882+ if ( outcome === timeoutToken ) {
883+ void observedTurnPromise . then ( ( lateOutcome ) => {
884+ if ( lateOutcome . kind === "error" ) {
885+ logVerbose (
886+ `acp-manager: detached late turn error after timeout for ${ params . sessionKey } : ${ String ( lateOutcome . error ) } ` ,
887+ ) ;
888+ }
889+ } ) ;
890+ await params . onTimeout ( ) ;
891+ throw new AcpRuntimeError (
892+ "ACP_TURN_FAILED" ,
893+ `ACP turn timed out after ${ Math . max ( 1 , Math . round ( params . timeoutLabelMs / 1_000 ) ) } s.` ,
894+ ) ;
895+ }
896+ if ( outcome . kind === "error" ) {
897+ throw outcome . error ;
898+ }
899+ return outcome . value ;
900+ } finally {
901+ if ( timer ) {
902+ clearTimeout ( timer ) ;
903+ }
904+ }
905+ }
906+
907+ private async cleanupTimedOutTurn ( params : {
908+ sessionKey : string ;
909+ activeTurn : ActiveTurnState ;
910+ mode : AcpRuntimeSessionMode ;
911+ } ) : Promise < void > {
912+ params . activeTurn . abortController . abort ( ) ;
913+ if ( ! params . activeTurn . cancelPromise ) {
914+ params . activeTurn . cancelPromise = params . activeTurn . runtime . cancel ( {
915+ handle : params . activeTurn . handle ,
916+ reason : ACP_TURN_TIMEOUT_REASON ,
917+ } ) ;
918+ }
919+ const cancelFinished = await this . awaitCleanupWithGrace ( {
920+ sessionKey : params . sessionKey ,
921+ label : "cancel" ,
922+ promise : params . activeTurn . cancelPromise ,
923+ } ) ;
924+ if ( params . mode !== "oneshot" ) {
925+ return ;
926+ }
927+ const closePromise = params . activeTurn . runtime . close ( {
928+ handle : params . activeTurn . handle ,
929+ reason : ACP_TURN_TIMEOUT_REASON ,
930+ } ) ;
931+ const closeFinished = await this . awaitCleanupWithGrace ( {
932+ sessionKey : params . sessionKey ,
933+ label : "close" ,
934+ promise : closePromise ,
935+ } ) ;
936+ if ( cancelFinished && closeFinished ) {
937+ this . clearCachedRuntimeStateIfHandleMatches ( {
938+ sessionKey : params . sessionKey ,
939+ handle : params . activeTurn . handle ,
940+ } ) ;
941+ return ;
942+ }
943+ void Promise . allSettled ( [ params . activeTurn . cancelPromise , closePromise ] ) . then ( ( ) => {
944+ this . clearCachedRuntimeStateIfHandleMatches ( {
945+ sessionKey : params . sessionKey ,
946+ handle : params . activeTurn . handle ,
947+ } ) ;
948+ } ) ;
949+ }
950+
951+ private async awaitCleanupWithGrace ( params : {
952+ sessionKey : string ;
953+ label : "cancel" | "close" ;
954+ promise : Promise < unknown > ;
955+ } ) : Promise < boolean > {
956+ const observedCleanupPromise : Promise <
957+ | {
958+ kind : "done" ;
959+ }
960+ | {
961+ kind : "error" ;
962+ error : unknown ;
963+ }
964+ > = params . promise . then (
965+ ( ) => ( {
966+ kind : "done" as const ,
967+ } ) ,
968+ ( error ) => ( {
969+ kind : "error" as const ,
970+ error,
971+ } ) ,
972+ ) ;
973+ const timeoutToken = Symbol ( `acp-timeout-${ params . label } ` ) ;
974+ let timer : NodeJS . Timeout | undefined ;
975+ const timeoutPromise = new Promise < typeof timeoutToken > ( ( resolve ) => {
976+ timer = setTimeout ( ( ) => resolve ( timeoutToken ) , ACP_TURN_TIMEOUT_CLEANUP_GRACE_MS ) ;
977+ timer . unref ?.( ) ;
978+ } ) ;
979+
980+ try {
981+ const outcome = await Promise . race ( [ observedCleanupPromise , timeoutPromise ] ) ;
982+ if ( outcome === timeoutToken ) {
983+ void observedCleanupPromise . then ( ( lateOutcome ) => {
984+ if ( lateOutcome . kind === "error" ) {
985+ logVerbose (
986+ `acp-manager: detached timed-out turn ${ params . label } cleanup failed for ${ params . sessionKey } : ${ String ( lateOutcome . error ) } ` ,
987+ ) ;
988+ }
989+ } ) ;
990+ logVerbose (
991+ `acp-manager: timed-out turn ${ params . label } cleanup exceeded ${ ACP_TURN_TIMEOUT_CLEANUP_GRACE_MS } ms for ${ params . sessionKey } ` ,
992+ ) ;
993+ return false ;
994+ }
995+ if ( outcome . kind === "error" ) {
996+ logVerbose (
997+ `acp-manager: timed-out turn ${ params . label } cleanup failed for ${ params . sessionKey } : ${ String ( outcome . error ) } ` ,
998+ ) ;
999+ }
1000+ return true ;
1001+ } finally {
1002+ if ( timer ) {
1003+ clearTimeout ( timer ) ;
1004+ }
1005+ }
1006+ }
1007+
7701008 async cancelSession ( params : {
7711009 cfg : OpenClawConfig ;
7721010 sessionKey : string ;
@@ -1418,4 +1656,27 @@ export class AcpSessionManager {
14181656 private clearCachedRuntimeState ( sessionKey : string ) : void {
14191657 this . runtimeCache . clear ( normalizeActorKey ( sessionKey ) ) ;
14201658 }
1659+
1660+ private clearCachedRuntimeStateIfHandleMatches ( params : {
1661+ sessionKey : string ;
1662+ handle : AcpRuntimeHandle ;
1663+ } ) : void {
1664+ const cached = this . getCachedRuntimeState ( params . sessionKey ) ;
1665+ if ( ! cached || ! this . runtimeHandlesMatch ( cached . handle , params . handle ) ) {
1666+ return ;
1667+ }
1668+ this . clearCachedRuntimeState ( params . sessionKey ) ;
1669+ }
1670+
1671+ private runtimeHandlesMatch ( a : AcpRuntimeHandle , b : AcpRuntimeHandle ) : boolean {
1672+ return (
1673+ a . sessionKey === b . sessionKey &&
1674+ a . backend === b . backend &&
1675+ a . runtimeSessionName === b . runtimeSessionName &&
1676+ ( a . cwd ?? "" ) === ( b . cwd ?? "" ) &&
1677+ ( a . acpxRecordId ?? "" ) === ( b . acpxRecordId ?? "" ) &&
1678+ ( a . backendSessionId ?? "" ) === ( b . backendSessionId ?? "" ) &&
1679+ ( a . agentSessionId ?? "" ) === ( b . agentSessionId ?? "" )
1680+ ) ;
1681+ }
14211682}
0 commit comments