dtSpark 1.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. dtSpark/__init__.py +0 -0
  2. dtSpark/_description.txt +1 -0
  3. dtSpark/_full_name.txt +1 -0
  4. dtSpark/_licence.txt +21 -0
  5. dtSpark/_metadata.yaml +6 -0
  6. dtSpark/_name.txt +1 -0
  7. dtSpark/_version.txt +1 -0
  8. dtSpark/aws/__init__.py +7 -0
  9. dtSpark/aws/authentication.py +296 -0
  10. dtSpark/aws/bedrock.py +578 -0
  11. dtSpark/aws/costs.py +318 -0
  12. dtSpark/aws/pricing.py +580 -0
  13. dtSpark/cli_interface.py +2645 -0
  14. dtSpark/conversation_manager.py +3050 -0
  15. dtSpark/core/__init__.py +12 -0
  16. dtSpark/core/application.py +3355 -0
  17. dtSpark/core/context_compaction.py +735 -0
  18. dtSpark/daemon/__init__.py +104 -0
  19. dtSpark/daemon/__main__.py +10 -0
  20. dtSpark/daemon/action_monitor.py +213 -0
  21. dtSpark/daemon/daemon_app.py +730 -0
  22. dtSpark/daemon/daemon_manager.py +289 -0
  23. dtSpark/daemon/execution_coordinator.py +194 -0
  24. dtSpark/daemon/pid_file.py +169 -0
  25. dtSpark/database/__init__.py +482 -0
  26. dtSpark/database/autonomous_actions.py +1191 -0
  27. dtSpark/database/backends.py +329 -0
  28. dtSpark/database/connection.py +122 -0
  29. dtSpark/database/conversations.py +520 -0
  30. dtSpark/database/credential_prompt.py +218 -0
  31. dtSpark/database/files.py +205 -0
  32. dtSpark/database/mcp_ops.py +355 -0
  33. dtSpark/database/messages.py +161 -0
  34. dtSpark/database/schema.py +673 -0
  35. dtSpark/database/tool_permissions.py +186 -0
  36. dtSpark/database/usage.py +167 -0
  37. dtSpark/files/__init__.py +4 -0
  38. dtSpark/files/manager.py +322 -0
  39. dtSpark/launch.py +39 -0
  40. dtSpark/limits/__init__.py +10 -0
  41. dtSpark/limits/costs.py +296 -0
  42. dtSpark/limits/tokens.py +342 -0
  43. dtSpark/llm/__init__.py +17 -0
  44. dtSpark/llm/anthropic_direct.py +446 -0
  45. dtSpark/llm/base.py +146 -0
  46. dtSpark/llm/context_limits.py +438 -0
  47. dtSpark/llm/manager.py +177 -0
  48. dtSpark/llm/ollama.py +578 -0
  49. dtSpark/mcp_integration/__init__.py +5 -0
  50. dtSpark/mcp_integration/manager.py +653 -0
  51. dtSpark/mcp_integration/tool_selector.py +225 -0
  52. dtSpark/resources/config.yaml.template +631 -0
  53. dtSpark/safety/__init__.py +22 -0
  54. dtSpark/safety/llm_service.py +111 -0
  55. dtSpark/safety/patterns.py +229 -0
  56. dtSpark/safety/prompt_inspector.py +442 -0
  57. dtSpark/safety/violation_logger.py +346 -0
  58. dtSpark/scheduler/__init__.py +20 -0
  59. dtSpark/scheduler/creation_tools.py +599 -0
  60. dtSpark/scheduler/execution_queue.py +159 -0
  61. dtSpark/scheduler/executor.py +1152 -0
  62. dtSpark/scheduler/manager.py +395 -0
  63. dtSpark/tools/__init__.py +4 -0
  64. dtSpark/tools/builtin.py +833 -0
  65. dtSpark/web/__init__.py +20 -0
  66. dtSpark/web/auth.py +152 -0
  67. dtSpark/web/dependencies.py +37 -0
  68. dtSpark/web/endpoints/__init__.py +17 -0
  69. dtSpark/web/endpoints/autonomous_actions.py +1125 -0
  70. dtSpark/web/endpoints/chat.py +621 -0
  71. dtSpark/web/endpoints/conversations.py +353 -0
  72. dtSpark/web/endpoints/main_menu.py +547 -0
  73. dtSpark/web/endpoints/streaming.py +421 -0
  74. dtSpark/web/server.py +578 -0
  75. dtSpark/web/session.py +167 -0
  76. dtSpark/web/ssl_utils.py +195 -0
  77. dtSpark/web/static/css/dark-theme.css +427 -0
  78. dtSpark/web/static/js/actions.js +1101 -0
  79. dtSpark/web/static/js/chat.js +614 -0
  80. dtSpark/web/static/js/main.js +496 -0
  81. dtSpark/web/static/js/sse-client.js +242 -0
  82. dtSpark/web/templates/actions.html +408 -0
  83. dtSpark/web/templates/base.html +93 -0
  84. dtSpark/web/templates/chat.html +814 -0
  85. dtSpark/web/templates/conversations.html +350 -0
  86. dtSpark/web/templates/goodbye.html +81 -0
  87. dtSpark/web/templates/login.html +90 -0
  88. dtSpark/web/templates/main_menu.html +983 -0
  89. dtSpark/web/templates/new_conversation.html +191 -0
  90. dtSpark/web/web_interface.py +137 -0
  91. dtspark-1.0.4.dist-info/METADATA +187 -0
  92. dtspark-1.0.4.dist-info/RECORD +96 -0
  93. dtspark-1.0.4.dist-info/WHEEL +5 -0
  94. dtspark-1.0.4.dist-info/entry_points.txt +3 -0
  95. dtspark-1.0.4.dist-info/licenses/LICENSE +21 -0
  96. dtspark-1.0.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,289 @@
1
+ """
2
+ Daemon lifecycle manager.
3
+
4
+ Provides CLI commands for starting, stopping, and checking daemon status.
5
+
6
+
7
+ """
8
+
9
+ import os
10
+ import sys
11
+ import time
12
+ import signal
13
+ import logging
14
+ import subprocess
15
+ from typing import List, Optional
16
+
17
+ from .pid_file import PIDFile
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class DaemonManager:
23
+ """
24
+ Manages daemon lifecycle via CLI commands.
25
+
26
+ Supports: start, stop, status, restart
27
+ Works across Windows and Unix platforms.
28
+ """
29
+
30
+ def __init__(self, pid_file_path: str):
31
+ """
32
+ Initialise the daemon manager.
33
+
34
+ Args:
35
+ pid_file_path: Path to the PID file
36
+ """
37
+ self.pid_file = PIDFile(pid_file_path)
38
+
39
+ def start(self, args: Optional[List[str]] = None) -> int:
40
+ """
41
+ Start the daemon process.
42
+
43
+ Args:
44
+ args: Additional arguments to pass to daemon
45
+
46
+ Returns:
47
+ 0 on success, non-zero on failure
48
+ """
49
+ # Check if already running
50
+ if self.pid_file.is_running():
51
+ pid = self.pid_file.read_pid()
52
+ print(f"Daemon already running (PID: {pid})")
53
+ return 1
54
+
55
+ # Check for --foreground flag
56
+ foreground = False
57
+ if args and '--foreground' in args:
58
+ foreground = True
59
+ args = [a for a in args if a != '--foreground']
60
+
61
+ if foreground:
62
+ # Run in foreground (for debugging)
63
+ return self._run_foreground(args)
64
+ else:
65
+ # Start in background
66
+ return self._start_background(args)
67
+
68
+ def _run_foreground(self, args: Optional[List[str]] = None) -> int:
69
+ """Run daemon in foreground mode."""
70
+ print("Starting daemon in foreground mode...")
71
+ print("Press Ctrl+C to stop")
72
+ print("")
73
+
74
+ # Import and run directly
75
+ from .daemon_app import DaemonApplication
76
+ app = DaemonApplication()
77
+
78
+ # Clean up sys.argv - AbstractApp expects only arguments it recognises
79
+ # Remove 'daemon', 'start', '--foreground' and keep only daemon-specific args
80
+ clean_args = ['dtSpark-daemon']
81
+ if args:
82
+ clean_args.extend(args)
83
+ sys.argv = clean_args
84
+
85
+ try:
86
+ app.run()
87
+ return 0
88
+ except KeyboardInterrupt:
89
+ print("\nDaemon stopped by user")
90
+ return 0
91
+ except Exception as e:
92
+ print(f"Daemon error: {e}")
93
+ import traceback
94
+ traceback.print_exc()
95
+ return 1
96
+
97
+ def _start_background(self, args: Optional[List[str]] = None) -> int:
98
+ """Start daemon as a background process."""
99
+ # Build command to run daemon
100
+ daemon_cmd = [
101
+ sys.executable,
102
+ '-m', 'dtSpark.daemon',
103
+ '--run'
104
+ ]
105
+ if args:
106
+ daemon_cmd.extend(args)
107
+
108
+ # Log file for daemon output (helps with debugging startup issues)
109
+ daemon_log_path = str(self.pid_file.path) + '.log'
110
+
111
+ try:
112
+ # Open log file for daemon output
113
+ log_file = open(daemon_log_path, 'w')
114
+
115
+ if sys.platform == 'win32':
116
+ # Windows: use CREATE_NEW_PROCESS_GROUP and DETACHED_PROCESS
117
+ process = subprocess.Popen(
118
+ daemon_cmd,
119
+ creationflags=(
120
+ subprocess.CREATE_NEW_PROCESS_GROUP |
121
+ subprocess.DETACHED_PROCESS
122
+ ),
123
+ stdout=log_file,
124
+ stderr=log_file,
125
+ stdin=subprocess.DEVNULL,
126
+ )
127
+ else:
128
+ # Unix: start new session
129
+ process = subprocess.Popen(
130
+ daemon_cmd,
131
+ start_new_session=True,
132
+ stdout=log_file,
133
+ stderr=log_file,
134
+ stdin=subprocess.DEVNULL,
135
+ )
136
+
137
+ # Wait for daemon to start and write PID file
138
+ # Poll with increasing intervals up to a maximum wait time
139
+ max_wait = 10 # seconds
140
+ waited = 0
141
+ check_interval = 0.5
142
+
143
+ while waited < max_wait:
144
+ time.sleep(check_interval)
145
+ waited += check_interval
146
+
147
+ if self.pid_file.is_running():
148
+ pid = self.pid_file.read_pid()
149
+ print(f"Daemon started (PID: {pid})")
150
+ print(f"Daemon log: {daemon_log_path}")
151
+ return 0
152
+
153
+ # Daemon didn't start within timeout
154
+ print("Daemon failed to start. Check daemon log for details:")
155
+ print(f" {daemon_log_path}")
156
+ # Try to show recent log content
157
+ try:
158
+ log_file.close()
159
+ with open(daemon_log_path, 'r') as f:
160
+ content = f.read()
161
+ if content:
162
+ print("\nDaemon output:")
163
+ print(content[-2000:] if len(content) > 2000 else content)
164
+ except Exception:
165
+ pass
166
+ return 1
167
+
168
+ except Exception as e:
169
+ print(f"Failed to start daemon: {e}")
170
+ logger.error(f"Failed to start daemon: {e}", exc_info=True)
171
+ return 1
172
+
173
+ def stop(self, timeout: int = 30) -> int:
174
+ """
175
+ Stop the daemon gracefully.
176
+
177
+ Args:
178
+ timeout: Seconds to wait for graceful shutdown
179
+
180
+ Returns:
181
+ 0 on success, non-zero on failure
182
+ """
183
+ pid = self.pid_file.read_pid()
184
+ if not pid or not self.pid_file.is_running():
185
+ print("Daemon is not running")
186
+ # Clean up stale PID file if exists
187
+ self.pid_file.remove()
188
+ return 0
189
+
190
+ print(f"Stopping daemon (PID: {pid})...")
191
+
192
+ # Send termination signal
193
+ try:
194
+ if sys.platform == 'win32':
195
+ # Windows: Create a stop signal file that the daemon checks
196
+ # This is more reliable than signals across console sessions
197
+ stop_file = str(self.pid_file.path) + '.stop'
198
+ try:
199
+ with open(stop_file, 'w') as f:
200
+ f.write(str(pid))
201
+ print("Stop signal sent")
202
+ except Exception as e:
203
+ print(f"Failed to create stop signal: {e}")
204
+ # Fall back to taskkill /F if signal file fails
205
+ subprocess.run(['taskkill', '/F', '/PID', str(pid)], capture_output=True)
206
+ else:
207
+ # Unix: SIGTERM for graceful shutdown
208
+ os.kill(pid, signal.SIGTERM)
209
+ except ProcessLookupError:
210
+ print("Daemon process not found")
211
+ self.pid_file.remove()
212
+ return 0
213
+ except PermissionError:
214
+ print("Permission denied to stop daemon")
215
+ return 1
216
+ except Exception as e:
217
+ print(f"Error stopping daemon: {e}")
218
+ return 1
219
+
220
+ # Wait for graceful shutdown
221
+ for i in range(timeout):
222
+ if not self.pid_file.is_running():
223
+ print("Daemon stopped")
224
+ self._cleanup_stop_file()
225
+ return 0
226
+ time.sleep(1)
227
+ if (i + 1) % 5 == 0:
228
+ print(f"Waiting for shutdown... ({i + 1}/{timeout}s)")
229
+
230
+ # Process didn't stop gracefully - clean up and report
231
+ self._cleanup_stop_file()
232
+ print(f"Daemon did not stop within {timeout} seconds")
233
+ if sys.platform == 'win32':
234
+ print("Forcing termination...")
235
+ subprocess.run(['taskkill', '/F', '/PID', str(pid)], capture_output=True)
236
+ time.sleep(1)
237
+ if not self.pid_file.is_running():
238
+ print("Daemon terminated")
239
+ return 0
240
+ print("Consider using 'kill -9' manually if needed")
241
+ return 1
242
+
243
+ def _cleanup_stop_file(self):
244
+ """Remove the stop signal file if it exists."""
245
+ stop_file = str(self.pid_file.path) + '.stop'
246
+ try:
247
+ if os.path.exists(stop_file):
248
+ os.remove(stop_file)
249
+ except Exception:
250
+ pass
251
+
252
+ def status(self) -> int:
253
+ """
254
+ Check daemon status.
255
+
256
+ Returns:
257
+ 0 if running, 1 if not running
258
+ """
259
+ pid = self.pid_file.read_pid()
260
+
261
+ if pid and self.pid_file.is_running():
262
+ print(f"Daemon is running (PID: {pid})")
263
+ return 0
264
+ else:
265
+ print("Daemon is not running")
266
+ # Clean up stale PID file if exists
267
+ if pid:
268
+ self.pid_file.remove()
269
+ return 1
270
+
271
+ def restart(self, args: Optional[List[str]] = None) -> int:
272
+ """
273
+ Restart the daemon.
274
+
275
+ Args:
276
+ args: Additional arguments to pass to daemon
277
+
278
+ Returns:
279
+ 0 on success, non-zero on failure
280
+ """
281
+ # Stop if running
282
+ if self.pid_file.is_running():
283
+ stop_result = self.stop()
284
+ if stop_result != 0:
285
+ return stop_result
286
+ time.sleep(2)
287
+
288
+ # Start daemon
289
+ return self.start(args)
@@ -0,0 +1,194 @@
1
+ """
2
+ Execution coordinator for preventing duplicate action runs.
3
+
4
+ Provides functionality for:
5
+ - Acquiring execution locks on actions
6
+ - Preventing concurrent execution by daemon and UI
7
+ - Cleaning up stale locks from crashed processes
8
+
9
+
10
+ """
11
+
12
+ import logging
13
+ from datetime import datetime
14
+ from typing import Optional
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class ActionLockedError(Exception):
20
+ """Raised when an action is locked by another process."""
21
+
22
+ def __init__(self, action_id: int, locked_by: str, message: str = None):
23
+ self.action_id = action_id
24
+ self.locked_by = locked_by
25
+ self.message = message or f"Action {action_id} is locked by {locked_by}"
26
+ super().__init__(self.message)
27
+
28
+
29
+ class ExecutionCoordinator:
30
+ """
31
+ Coordinates action execution to prevent conflicts.
32
+
33
+ Uses database locking mechanism to prevent duplicate execution
34
+ when both daemon and UI try to run the same action.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ database,
40
+ process_id: str,
41
+ user_guid: str,
42
+ lock_timeout_seconds: int = 300
43
+ ):
44
+ """
45
+ Initialise the execution coordinator.
46
+
47
+ Args:
48
+ database: Database instance with autonomous_actions methods
49
+ process_id: Unique identifier for this process (daemon_id or session_id)
50
+ user_guid: User GUID for database operations
51
+ lock_timeout_seconds: Seconds after which a lock is considered stale
52
+ """
53
+ self.database = database
54
+ self.process_id = process_id
55
+ self.user_guid = user_guid
56
+ self.lock_timeout_seconds = lock_timeout_seconds
57
+
58
+ def try_acquire_lock(self, action_id: int) -> bool:
59
+ """
60
+ Attempt to acquire an execution lock for an action.
61
+
62
+ Args:
63
+ action_id: ID of the action to lock
64
+
65
+ Returns:
66
+ True if lock acquired, False if another process holds it
67
+ """
68
+ # First, clear any stale locks
69
+ self._clear_stale_locks()
70
+
71
+ # Try to acquire lock
72
+ from dtSpark.database.autonomous_actions import try_lock_action
73
+ success = try_lock_action(
74
+ conn=self.database.conn,
75
+ action_id=action_id,
76
+ locked_by=self.process_id,
77
+ user_guid=self.user_guid
78
+ )
79
+
80
+ if success:
81
+ logger.debug(f"Acquired lock on action {action_id}")
82
+ else:
83
+ logger.debug(f"Failed to acquire lock on action {action_id}")
84
+
85
+ return success
86
+
87
+ def release_lock(self, action_id: int) -> bool:
88
+ """
89
+ Release an execution lock for an action.
90
+
91
+ Args:
92
+ action_id: ID of the action to unlock
93
+
94
+ Returns:
95
+ True if unlocked successfully, False otherwise
96
+ """
97
+ from dtSpark.database.autonomous_actions import unlock_action
98
+ success = unlock_action(
99
+ conn=self.database.conn,
100
+ action_id=action_id,
101
+ locked_by=self.process_id,
102
+ user_guid=self.user_guid
103
+ )
104
+
105
+ if success:
106
+ logger.debug(f"Released lock on action {action_id}")
107
+ else:
108
+ logger.warning(f"Failed to release lock on action {action_id}")
109
+
110
+ return success
111
+
112
+ def is_action_locked(self, action_id: int) -> bool:
113
+ """
114
+ Check if an action is currently locked by another process.
115
+
116
+ Args:
117
+ action_id: ID of the action to check
118
+
119
+ Returns:
120
+ True if locked by another process, False otherwise
121
+ """
122
+ from dtSpark.database.autonomous_actions import get_action_lock_info
123
+ lock_info = get_action_lock_info(
124
+ conn=self.database.conn,
125
+ action_id=action_id,
126
+ user_guid=self.user_guid
127
+ )
128
+
129
+ if not lock_info or not lock_info.get('locked_by'):
130
+ return False
131
+
132
+ # Not locked by another process if we hold the lock
133
+ if lock_info['locked_by'] == self.process_id:
134
+ return False
135
+
136
+ return True
137
+
138
+ def get_lock_holder(self, action_id: int) -> Optional[str]:
139
+ """
140
+ Get the identifier of the process holding the lock.
141
+
142
+ Args:
143
+ action_id: ID of the action
144
+
145
+ Returns:
146
+ Process identifier if locked, None otherwise
147
+ """
148
+ from dtSpark.database.autonomous_actions import get_action_lock_info
149
+ lock_info = get_action_lock_info(
150
+ conn=self.database.conn,
151
+ action_id=action_id,
152
+ user_guid=self.user_guid
153
+ )
154
+
155
+ if lock_info:
156
+ return lock_info.get('locked_by')
157
+ return None
158
+
159
+ def _clear_stale_locks(self):
160
+ """Clear locks that are older than the timeout."""
161
+ from dtSpark.database.autonomous_actions import clear_stale_locks
162
+ clear_stale_locks(
163
+ conn=self.database.conn,
164
+ lock_timeout_seconds=self.lock_timeout_seconds,
165
+ user_guid=self.user_guid
166
+ )
167
+
168
+ def execute_with_lock(self, action_id: int, execute_func, *args, **kwargs):
169
+ """
170
+ Execute a function while holding an action lock.
171
+
172
+ Args:
173
+ action_id: ID of the action to lock
174
+ execute_func: Function to execute
175
+ *args, **kwargs: Arguments to pass to the function
176
+
177
+ Returns:
178
+ Result of execute_func
179
+
180
+ Raises:
181
+ ActionLockedError: If action is locked by another process
182
+ """
183
+ if not self.try_acquire_lock(action_id):
184
+ lock_holder = self.get_lock_holder(action_id)
185
+ raise ActionLockedError(
186
+ action_id=action_id,
187
+ locked_by=lock_holder or "unknown",
188
+ message=f"Action {action_id} is currently being executed by another process"
189
+ )
190
+
191
+ try:
192
+ return execute_func(*args, **kwargs)
193
+ finally:
194
+ self.release_lock(action_id)
@@ -0,0 +1,169 @@
1
+ """
2
+ PID file management for daemon process.
3
+
4
+ Provides functionality for:
5
+ - Writing and reading PID files
6
+ - Checking if a process is running
7
+ - Preventing multiple daemon instances
8
+
9
+
10
+ """
11
+
12
+ import os
13
+ import sys
14
+ import logging
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class PIDFile:
22
+ """
23
+ Manages PID file for daemon process.
24
+
25
+ Prevents multiple daemon instances and enables status checking.
26
+ Works across Windows and Unix platforms.
27
+ """
28
+
29
+ def __init__(self, path: str):
30
+ """
31
+ Initialise the PID file manager.
32
+
33
+ Args:
34
+ path: Path to the PID file
35
+ """
36
+ self.path = Path(path)
37
+
38
+ def write_pid(self, pid: Optional[int] = None):
39
+ """
40
+ Write current process PID to file.
41
+
42
+ Args:
43
+ pid: Process ID to write (default: current process)
44
+ """
45
+ if pid is None:
46
+ pid = os.getpid()
47
+
48
+ # Ensure parent directory exists
49
+ self.path.parent.mkdir(parents=True, exist_ok=True)
50
+
51
+ self.path.write_text(str(pid))
52
+ logger.info(f"PID file written: {self.path} (PID: {pid})")
53
+
54
+ def read_pid(self) -> Optional[int]:
55
+ """
56
+ Read PID from file.
57
+
58
+ Returns:
59
+ Process ID if file exists and is valid, None otherwise
60
+ """
61
+ try:
62
+ return int(self.path.read_text().strip())
63
+ except FileNotFoundError:
64
+ return None
65
+ except ValueError:
66
+ logger.warning(f"Invalid PID file contents: {self.path}")
67
+ return None
68
+
69
+ def remove(self):
70
+ """Remove PID file."""
71
+ try:
72
+ self.path.unlink()
73
+ logger.info(f"PID file removed: {self.path}")
74
+ except FileNotFoundError:
75
+ pass
76
+
77
+ def is_running(self) -> bool:
78
+ """
79
+ Check if process with stored PID is running.
80
+
81
+ Returns:
82
+ True if process is running, False otherwise
83
+ """
84
+ pid = self.read_pid()
85
+ if not pid:
86
+ return False
87
+
88
+ return self._is_process_running(pid)
89
+
90
+ def _is_process_running(self, pid: int) -> bool:
91
+ """
92
+ Check if a process with the given PID is running.
93
+
94
+ Args:
95
+ pid: Process ID to check
96
+
97
+ Returns:
98
+ True if process is running, False otherwise
99
+ """
100
+ if sys.platform == 'win32':
101
+ return self._is_process_running_windows(pid)
102
+ else:
103
+ return self._is_process_running_unix(pid)
104
+
105
+ def _is_process_running_windows(self, pid: int) -> bool:
106
+ """Check if process is running on Windows."""
107
+ try:
108
+ import ctypes
109
+ kernel32 = ctypes.windll.kernel32
110
+
111
+ # PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
112
+ handle = kernel32.OpenProcess(0x1000, False, pid)
113
+ if handle:
114
+ kernel32.CloseHandle(handle)
115
+ return True
116
+ return False
117
+ except Exception as e:
118
+ logger.debug(f"Error checking process on Windows: {e}")
119
+ return False
120
+
121
+ def _is_process_running_unix(self, pid: int) -> bool:
122
+ """Check if process is running on Unix."""
123
+ try:
124
+ # Signal 0 doesn't actually send a signal, just checks if process exists
125
+ os.kill(pid, 0)
126
+ return True
127
+ except ProcessLookupError:
128
+ return False
129
+ except PermissionError:
130
+ # Process exists but we don't have permission to signal it
131
+ return True
132
+ except OSError:
133
+ return False
134
+
135
+ def acquire(self) -> bool:
136
+ """
137
+ Attempt to acquire the PID file (atomic operation).
138
+
139
+ Returns:
140
+ True if acquired successfully, False if daemon already running
141
+ """
142
+ if self.is_running():
143
+ existing_pid = self.read_pid()
144
+ logger.warning(f"Daemon already running with PID {existing_pid}")
145
+ return False
146
+
147
+ # Clean up stale PID file if process is not running
148
+ if self.path.exists():
149
+ logger.info("Removing stale PID file")
150
+ self.remove()
151
+
152
+ self.write_pid()
153
+ return True
154
+
155
+ def release(self):
156
+ """
157
+ Release the PID file.
158
+
159
+ Only removes if the current process holds it.
160
+ """
161
+ current_pid = os.getpid()
162
+ stored_pid = self.read_pid()
163
+
164
+ if stored_pid == current_pid:
165
+ self.remove()
166
+ else:
167
+ logger.warning(
168
+ f"PID file contains different PID (stored: {stored_pid}, current: {current_pid})"
169
+ )