7878 from shared .model_selector import TaskType , TaskComplexity
7979
8080
81+ # Default heartbeat interval for long-running CLI operations
82+ DEFAULT_HEARTBEAT_INTERVAL = 60.0 # Log heartbeat every 60 seconds
83+
84+
8185class RateLimitExceeded (Exception ):
8286 """Exception raised when all backends are rate limited."""
8387
@@ -98,6 +102,55 @@ def __init__(self, message: str, backend: str, wait_time: Optional[float] = None
98102 self .wait_time = wait_time
99103
100104
105+ async def _communicate_with_heartbeat (
106+ process : asyncio .subprocess .Process ,
107+ input_data : Optional [bytes ],
108+ start_time : float ,
109+ heartbeat_interval : float = DEFAULT_HEARTBEAT_INTERVAL ,
110+ label : str = "CLI" ,
111+ ) -> tuple [bytes , bytes ]:
112+ """
113+ Communicate with a subprocess while logging periodic heartbeats.
114+
115+ This helps track long-running operations and provides visibility
116+ into whether the process is still active.
117+
118+ Args:
119+ process: The subprocess to communicate with
120+ input_data: Data to send to stdin (or None)
121+ start_time: When the operation started (for elapsed time calculation)
122+ heartbeat_interval: Seconds between heartbeat messages
123+ label: Label to use in heartbeat messages (e.g., "Claude", "Copilot")
124+
125+ Returns:
126+ tuple[bytes, bytes]: (stdout, stderr) from the process
127+ """
128+
129+ async def heartbeat_logger ():
130+ """Log periodic heartbeats while waiting for process."""
131+ while True :
132+ await asyncio .sleep (heartbeat_interval )
133+ elapsed = time .time () - start_time
134+ print (
135+ f"[heartbeat] { label } still running... ({ elapsed :.0f} s elapsed)" ,
136+ file = sys .stderr ,
137+ )
138+
139+ heartbeat_task = asyncio .create_task (heartbeat_logger ())
140+ try :
141+ if input_data is not None :
142+ stdout , stderr = await process .communicate (input = input_data )
143+ else :
144+ stdout , stderr = await process .communicate ()
145+ return stdout , stderr
146+ finally :
147+ heartbeat_task .cancel ()
148+ try :
149+ await heartbeat_task
150+ except asyncio .CancelledError :
151+ pass
152+
153+
101154# =============================================================================
102155# Backend Management with Failover
103156# =============================================================================
@@ -762,6 +815,12 @@ async def analyze_file_with_cli(
762815## Architecture Overview
763816The Sharpy compiler pipeline flows: Source (.spy) → Lexer → Parser (AST) → Semantic Analysis → RoslynEmitter → C#
764817
818+ ## Important: Partial Classes
819+ Note that large modules in the Sharpy codebase are often split across multiple files using C# partial classes.
820+ For example, the standard library in `Sharpy.Core` uses `partial class Exports` spread across directories like
821+ `Partial.List/`, `Partial.Str/`, etc. If you see a partial class, you may need to reference related files
822+ to provide complete documentation. Include cross-referencing links to related partial class files when relevant.
823+
765824## Task
766825Create a walkthrough document covering:
767826
@@ -784,11 +843,16 @@ async def analyze_file_with_cli(
784843- Bullet points for lists
785844- Emphasis on readability for someone new to the codebase
786845
787- Focus on providing intuition and understanding, not just restating what the code does line-by-line."""
846+ Focus on providing intuition and understanding, not just restating what the code does line-by-line.
847+
848+ 8. **Cross-References**: If this file is a partial class or heavily depends on other files, include links to related documentation files."""
788849
789850 # Build the command for the specified CLI provider
790851 cmd , stdin_input = _build_cli_command (cli_provider , prompt )
791852
853+ # Determine label for heartbeat logging
854+ heartbeat_label = "Claude" if cli_provider == "claude" else "Copilot"
855+
792856 # Call the AI CLI in programmatic mode with write permissions
793857 # Change to the base directory so relative paths work
794858 # Using create_subprocess_exec (not shell) so the prompt argument is safely passed
@@ -804,8 +868,16 @@ async def analyze_file_with_cli(
804868 try :
805869 # Pass prompt via stdin if required (e.g., for Claude CLI)
806870 input_bytes = stdin_input .encode ("utf-8" ) if stdin_input else None
871+ # Use heartbeat-enabled communication for visibility during long operations
807872 stdout , stderr = await asyncio .wait_for (
808- process .communicate (input = input_bytes ), timeout = timeout
873+ _communicate_with_heartbeat (
874+ process ,
875+ input_data = input_bytes ,
876+ start_time = start_time ,
877+ heartbeat_interval = DEFAULT_HEARTBEAT_INTERVAL ,
878+ label = heartbeat_label ,
879+ ),
880+ timeout = timeout ,
809881 )
810882 duration = time .time () - start_time
811883
@@ -886,7 +958,9 @@ async def analyze_file_with_cli(
886958 is_stale = is_stale ,
887959 extra = {"backend" : cli_provider },
888960 )
889- print (f"✓ Generated: { output_path } (via { cli_provider } )" )
961+ print (
962+ f"✓ Generated: { output_path } (via { cli_provider } , { duration :.1f} s)"
963+ )
890964 return True , cli_provider
891965 else :
892966 log_execution (
@@ -901,13 +975,14 @@ async def analyze_file_with_cli(
901975 extra = {"backend" : cli_provider },
902976 )
903977 print (
904- f"Warning: CLI completed but output file not found: { output_path } " ,
978+ f"⚠ Warning: CLI completed but output file not found: { output_path } " ,
905979 file = sys .stderr ,
906980 )
907981 # Print CLI's response for debugging
908982 if stdout :
983+ cli_output = stdout .decode ("utf-8" )[:500 ]
909984 print (
910- f"CLI output: { stdout . decode ( 'utf-8' )[: 500 ] } " ,
985+ f" CLI output preview : { cli_output } " ,
911986 file = sys .stderr ,
912987 )
913988 return False , cli_provider
@@ -925,9 +1000,16 @@ async def analyze_file_with_cli(
9251000 is_stale = is_stale ,
9261001 extra = {"backend" : cli_provider },
9271002 )
928- print (f"Timeout analyzing { cs_file } " , file = sys .stderr )
929- process .kill ()
930- await process .wait ()
1003+ print (
1004+ f"⏱ Timeout analyzing { cs_file } after { timeout } s (backend: { cli_provider } )" ,
1005+ file = sys .stderr ,
1006+ )
1007+ # Kill the process on timeout
1008+ try :
1009+ process .kill ()
1010+ await process .wait ()
1011+ except Exception :
1012+ pass # Process may have already exited
9311013 return False , cli_provider
9321014
9331015 except BackendUnavailable :
@@ -1216,10 +1298,24 @@ async def main_async(config: Config):
12161298
12171299 # Wait between batches to avoid rate limiting (except for the last batch)
12181300 if batch_num + config .parallel_instances < len (files_to_process ):
1301+ remaining_batches = total_batches - current_batch_num
12191302 print (
1220- f"Waiting { config .timeout_between_batches } s to avoid rate limiting..."
1303+ f"\n ⏳ Waiting { config .timeout_between_batches } s before next batch "
1304+ f"({ remaining_batches } batches remaining)..."
12211305 )
1222- await asyncio .sleep (config .timeout_between_batches )
1306+ # Use a heartbeat during the wait period for long waits
1307+ wait_start = time .time ()
1308+ remaining_wait = config .timeout_between_batches
1309+ while remaining_wait > 0 :
1310+ sleep_chunk = min (remaining_wait , 30.0 ) # Check every 30s
1311+ await asyncio .sleep (sleep_chunk )
1312+ remaining_wait -= sleep_chunk
1313+ if remaining_wait > 0 and config .timeout_between_batches >= 60 :
1314+ elapsed = time .time () - wait_start
1315+ print (
1316+ f"[heartbeat] Batch wait: { elapsed :.0f} s / { config .timeout_between_batches } s" ,
1317+ file = sys .stderr ,
1318+ )
12231319 except RateLimitExceeded as e :
12241320 # All backends exhausted, stop processing
12251321 print (
0 commit comments