@@ -22,11 +22,9 @@ Examples:
2222from __future__ import annotations
2323
2424import os
25- import shutil
2625import subprocess
2726import sys
2827import tempfile
29- import time
3028from datetime import datetime
3129from pathlib import Path
3230
@@ -100,34 +98,90 @@ def _use_unified_daemon() -> bool:
10098 return True # Default to unified daemon
10199
102100
101+ def _maybe_start_unified_daemon () -> bool :
102+ """Try to start unified askd daemon if not running."""
103+ import shutil
104+ import time
105+ import sys
106+ from askd_runtime import state_file_path
107+ from askd .daemon import ping_daemon
108+
109+ # Check if already running
110+ state_file = state_file_path ("askd.json" )
111+ if ping_daemon (timeout_s = 0.5 , state_file = state_file ):
112+ return True
113+
114+ # Find askd binary
115+ candidates : list [str ] = []
116+ local = (Path (__file__ ).resolve ().parent / "askd" )
117+ if local .exists ():
118+ candidates .append (str (local ))
119+ found = shutil .which ("askd" )
120+ if found :
121+ candidates .append (found )
122+ if not candidates :
123+ return False
124+
125+ # Prepare command with cross-platform handling
126+ entry = candidates [0 ]
127+ lower = entry .lower ()
128+ if lower .endswith ((".cmd" , ".bat" , ".exe" )):
129+ argv = [entry ]
130+ else :
131+ argv = [sys .executable , entry ]
132+
133+ # Start daemon in background with platform-specific flags
134+ try :
135+ kwargs = {
136+ "stdin" : subprocess .DEVNULL ,
137+ "stdout" : subprocess .DEVNULL ,
138+ "stderr" : subprocess .DEVNULL ,
139+ "close_fds" : True ,
140+ }
141+ if os .name == "nt" :
142+ kwargs ["creationflags" ] = getattr (subprocess , "DETACHED_PROCESS" , 0 ) | getattr (subprocess , "CREATE_NEW_PROCESS_GROUP" , 0 )
143+ else :
144+ kwargs ["start_new_session" ] = True
145+ subprocess .Popen (argv , ** kwargs )
146+ except Exception :
147+ return False
148+
149+ # Wait for daemon to be ready
150+ deadline = time .time () + 2.0
151+ while time .time () < deadline :
152+ if ping_daemon (timeout_s = 0.2 , state_file = state_file ):
153+ return True
154+ time .sleep (0.1 )
155+
156+ return False
157+
158+
103159def _send_via_unified_daemon (
104160 provider : str ,
105161 message : str ,
106162 timeout : float ,
107163 no_wrap : bool ,
108164 caller : str ,
109165) -> int :
110- """Send request via unified askd daemon."""
166+ """Send request via unified askd daemon with auto-start retry ."""
111167 import json
112168 import socket
113169
114170 from askd_runtime import state_file_path
115171 import askd_rpc
116172
117- ready_timeout = min (timeout , 2.0 ) if timeout and timeout > 0 else 2.0
118- if not _ensure_unified_daemon_ready (timeout_s = ready_timeout ):
119- print ("[ERROR] Unified askd daemon not running" , file = sys .stderr )
120- print ("Start it with `askd` (or enable autostart via CCB_ASKD_AUTOSTART=1)." , file = sys .stderr )
121- return EXIT_ERROR
122-
123173 # Use CCB_RUN_DIR (set by CCB startup) to locate the state file.
124174 # This already contains the correct project-specific path.
125175 state_file = state_file_path ("askd.json" )
126176
127177 state = askd_rpc .read_state (state_file )
128178 if not state :
129- print ("[ERROR] Unified askd daemon not running" , file = sys .stderr )
130- return EXIT_ERROR
179+ # Try to start daemon and retry once
180+ if _maybe_start_unified_daemon ():
181+ state = askd_rpc .read_state (state_file )
182+ if not state :
183+ print ("[ERROR] Unified askd daemon not running" , file = sys .stderr )
184+ return EXIT_ERROR
131185
132186 host = state .get ("connect_host" ) or state .get ("host" ) or "127.0.0.1"
133187 port = int (state .get ("port" ) or 0 )
@@ -164,31 +218,74 @@ def _send_via_unified_daemon(
164218 req ["email_msg_id" ] = os .environ .get ("CCB_EMAIL_MSG_ID" , "" )
165219 req ["email_from" ] = os .environ .get ("CCB_EMAIL_FROM" , "" )
166220
167- try :
168- sock = socket .socket (socket .AF_INET , socket .SOCK_STREAM )
169- sock .settimeout (timeout + 10 if timeout > 0 else 3610 )
170- sock .connect ((host , port ))
171- sock .sendall ((json .dumps (req ) + "\n " ).encode ("utf-8" ))
172-
173- data = b""
174- while True :
175- chunk = sock .recv (4096 )
176- if not chunk :
177- break
178- data += chunk
179- if b"\n " in data :
180- break
181-
182- sock .close ()
183- resp = json .loads (data .decode ("utf-8" ).strip ())
184- exit_code = int (resp .get ("exit_code" ) or 0 )
185- reply = resp .get ("reply" ) or ""
186- if reply :
187- print (reply )
188- return exit_code
189- except Exception as e :
190- print (f"[ERROR] { e } " , file = sys .stderr )
191- return EXIT_ERROR
221+ # Try to send request, with one retry on connection failure only
222+ request_sent = False
223+ for attempt in range (2 ):
224+ sock = None
225+ try :
226+ sock = socket .socket (socket .AF_INET , socket .SOCK_STREAM )
227+ sock .settimeout (timeout + 10 if timeout > 0 else 3610 )
228+ sock .connect ((host , port ))
229+
230+ # Mark that connection succeeded - no retry after this point
231+ request_sent = True
232+
233+ sock .sendall ((json .dumps (req ) + "\n " ).encode ("utf-8" ))
234+
235+ data = b""
236+ while True :
237+ chunk = sock .recv (4096 )
238+ if not chunk :
239+ break
240+ data += chunk
241+ if b"\n " in data :
242+ break
243+
244+ resp = json .loads (data .decode ("utf-8" ).strip ())
245+ exit_code = int (resp .get ("exit_code" ) or 0 )
246+ reply = resp .get ("reply" ) or ""
247+ if reply :
248+ print (reply )
249+ return exit_code
250+ except (ConnectionRefusedError , ConnectionResetError ) as e :
251+ # Only retry if connection failed before request was sent
252+ if attempt == 0 and not request_sent :
253+ if _maybe_start_unified_daemon ():
254+ # Re-read state for new daemon
255+ state = askd_rpc .read_state (state_file )
256+ if state :
257+ host = state .get ("connect_host" ) or state .get ("host" ) or "127.0.0.1"
258+ port = int (state .get ("port" ) or 0 )
259+ token = state .get ("token" ) or ""
260+ req ["token" ] = token
261+ continue # Retry with new connection info
262+ print (f"[ERROR] { e } " , file = sys .stderr )
263+ return EXIT_ERROR
264+ except OSError as e :
265+ # For other OS errors, only retry if connection not yet established
266+ if attempt == 0 and not request_sent :
267+ if _maybe_start_unified_daemon ():
268+ state = askd_rpc .read_state (state_file )
269+ if state :
270+ host = state .get ("connect_host" ) or state .get ("host" ) or "127.0.0.1"
271+ port = int (state .get ("port" ) or 0 )
272+ token = state .get ("token" ) or ""
273+ req ["token" ] = token
274+ continue
275+ print (f"[ERROR] { e } " , file = sys .stderr )
276+ return EXIT_ERROR
277+ except Exception as e :
278+ print (f"[ERROR] { e } " , file = sys .stderr )
279+ return EXIT_ERROR
280+ finally :
281+ # Always close socket to prevent leaks
282+ if sock :
283+ try :
284+ sock .close ()
285+ except Exception :
286+ pass
287+
288+ return EXIT_ERROR
192289
193290
194291def _env_bool (name : str , default : bool = False ) -> bool :
@@ -198,96 +295,6 @@ def _env_bool(name: str, default: bool = False) -> bool:
198295 return val not in ("0" , "false" , "no" , "off" )
199296
200297
201- def _is_pid_alive (pid : int ) -> bool :
202- if pid <= 0 :
203- return False
204- try :
205- os .kill (pid , 0 )
206- return True
207- except OSError :
208- return False
209- except Exception :
210- return True
211-
212-
213- def _askd_start_argv () -> list [str ] | None :
214- local = script_dir / "askd"
215- candidates : list [str ] = []
216- if local .exists ():
217- candidates .append (str (local ))
218- found = shutil .which ("askd" )
219- if found :
220- candidates .append (found )
221- if not candidates :
222- return None
223-
224- entry = candidates [0 ]
225- lower = entry .lower ()
226- if lower .endswith ((".cmd" , ".bat" , ".exe" )):
227- return [entry ]
228- return [sys .executable , entry ]
229-
230-
231- def _ensure_unified_daemon_ready (timeout_s : float = 2.0 ) -> bool :
232- if not _use_unified_daemon ():
233- return True
234-
235- from askd_runtime import state_file_path
236- import askd_rpc
237-
238- state_file = state_file_path ("askd.json" )
239- try :
240- if askd_rpc .ping_daemon ("ask" , 0.2 , state_file ):
241- return True
242- except Exception :
243- pass
244-
245- if not _env_bool ("CCB_ASKD_AUTOSTART" , True ):
246- return False
247-
248- argv = _askd_start_argv ()
249- if not argv :
250- return False
251-
252- env = os .environ .copy ()
253- parent_raw = (env .get ("CCB_PARENT_PID" ) or "" ).strip ()
254- if parent_raw :
255- try :
256- parent_pid = int (parent_raw )
257- except Exception :
258- parent_pid = 0
259- if parent_pid <= 0 or not _is_pid_alive (parent_pid ):
260- env .pop ("CCB_PARENT_PID" , None )
261- env .pop ("CCB_MANAGED" , None )
262-
263- kwargs = {
264- "stdin" : subprocess .DEVNULL ,
265- "stdout" : subprocess .DEVNULL ,
266- "stderr" : subprocess .DEVNULL ,
267- "close_fds" : True ,
268- "env" : env ,
269- }
270- if os .name == "nt" :
271- kwargs ["creationflags" ] = getattr (subprocess , "DETACHED_PROCESS" , 0 ) | getattr (subprocess , "CREATE_NEW_PROCESS_GROUP" , 0 )
272- else :
273- kwargs ["start_new_session" ] = True
274-
275- try :
276- subprocess .Popen (argv , ** kwargs )
277- except Exception :
278- return False
279-
280- deadline = time .time () + max (0.2 , float (timeout_s ))
281- while time .time () < deadline :
282- try :
283- if askd_rpc .ping_daemon ("ask" , 0.2 , state_file ):
284- return True
285- except Exception :
286- pass
287- time .sleep (0.1 )
288- return False
289-
290-
291298def _default_foreground () -> bool :
292299 # Allow explicit override
293300 if _env_bool ("CCB_ASK_BACKGROUND" , False ):
@@ -394,10 +401,15 @@ def main(argv: list[str]) -> int:
394401 return EXIT_ERROR
395402
396403 # Notify mode: sync send, no wait for reply (used for hook notifications)
397- # MUST be checked before unified daemon path to avoid full request-response cycle
398- # which would cause reply-to-self loops via notify_completion -> ask -> daemon -> notify_completion
399404 if notify_mode :
400405 _require_caller ()
406+ if _use_unified_daemon ():
407+ # TODO: Add fire-and-forget RPC mode for unified daemon
408+ # For now, disable unified mode for notify and use legacy path
409+ print ("[WARN] Notify mode not yet supported with unified daemon, using legacy" , file = sys .stderr )
410+ # Fall through to legacy path below
411+
412+ # Legacy daemon path for notify mode
401413 cmd = [daemon_cmd , "--sync" ]
402414 if no_wrap :
403415 cmd .append ("--no-wrap" )
@@ -416,33 +428,25 @@ def main(argv: list[str]) -> int:
416428 print (f"[ERROR] { e } " , file = sys .stderr )
417429 return EXIT_ERROR
418430
419- # Use unified daemon if enabled (default: True)
420- if _use_unified_daemon ():
421- caller = _require_caller ()
422- return _send_via_unified_daemon (provider , message , timeout , no_wrap , caller )
423-
424- # Foreground mode: run provider directly (avoid background cleanup in managed envs)
431+ # Foreground mode: run provider directly via unified daemon
425432 if foreground_mode :
426- cmd = [daemon_cmd , "--sync" , "--timeout" , str (timeout )]
427- if no_wrap and provider == "claude" :
428- cmd .append ("--no-wrap" )
429- env = os .environ .copy ()
430- env ["CCB_CALLER" ] = _require_caller ()
431- try :
432- result = subprocess .run (cmd , input = message , text = True , env = env )
433- return result .returncode
434- except Exception as e :
435- print (f"[ERROR] { e } " , file = sys .stderr )
436- return EXIT_ERROR
437-
438- # Default async mode: background task via nohup, using unified askd daemon
439- if _use_unified_daemon ():
440- ready_timeout = min (timeout , 2.0 ) if timeout and timeout > 0 else 2.0
441- if not _ensure_unified_daemon_ready (timeout_s = ready_timeout ):
442- print ("[ERROR] Unified askd daemon not running" , file = sys .stderr )
443- print ("Start it with `askd` (or enable autostart via CCB_ASKD_AUTOSTART=1)." , file = sys .stderr )
444- return EXIT_ERROR
433+ if _use_unified_daemon ():
434+ caller = _require_caller ()
435+ return _send_via_unified_daemon (provider , message , timeout , no_wrap , caller )
436+ else :
437+ cmd = [daemon_cmd , "--sync" , "--timeout" , str (timeout )]
438+ if no_wrap and provider == "claude" :
439+ cmd .append ("--no-wrap" )
440+ env = os .environ .copy ()
441+ env ["CCB_CALLER" ] = _require_caller ()
442+ try :
443+ result = subprocess .run (cmd , input = message , text = True , env = env )
444+ return result .returncode
445+ except Exception as e :
446+ print (f"[ERROR] { e } " , file = sys .stderr )
447+ return EXIT_ERROR
445448
449+ # Default async mode: background task via nohup
446450 task_id = make_task_id ()
447451 log_dir = Path (tempfile .gettempdir ()) / "ccb-tasks"
448452 log_dir .mkdir (parents = True , exist_ok = True )
0 commit comments