gdmcode 0.1.0__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.
- gdmcode-0.1.0.dist-info/METADATA +240 -0
- gdmcode-0.1.0.dist-info/RECORD +131 -0
- gdmcode-0.1.0.dist-info/WHEEL +4 -0
- gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/_internal/__init__.py +0 -0
- src/_internal/constants.py +244 -0
- src/_internal/domain_skills.py +339 -0
- src/agent/__init__.py +0 -0
- src/agent/commit_classifier.py +91 -0
- src/agent/context_budget.py +391 -0
- src/agent/daemon.py +681 -0
- src/agent/dag_validator.py +153 -0
- src/agent/debug_loop.py +473 -0
- src/agent/impact_analyzer.py +149 -0
- src/agent/impact_graph.py +117 -0
- src/agent/loop.py +1410 -0
- src/agent/orchestrator.py +141 -0
- src/agent/regression_guard.py +251 -0
- src/agent/review_gate.py +648 -0
- src/agent/risk_scorer.py +169 -0
- src/agent/self_healing.py +145 -0
- src/agent/smart_test_selector.py +89 -0
- src/agent/system_prompt.py +226 -0
- src/agent/task_tracker.py +320 -0
- src/agent/test_validator.py +210 -0
- src/agent/tool_orchestrator.py +402 -0
- src/agent/transcript.py +230 -0
- src/agent/verification_loop.py +133 -0
- src/agent/work_director.py +136 -0
- src/agent/worktree_manager.py +53 -0
- src/artifacts/__init__.py +16 -0
- src/artifacts/artifact_store.py +456 -0
- src/artifacts/verification_graph.py +75 -0
- src/auth.py +411 -0
- src/cli.py +1290 -0
- src/commands.py +1398 -0
- src/config.py +762 -0
- src/cost_tracker.py +348 -0
- src/db/__init__.py +4 -0
- src/db/migrations.py +337 -0
- src/enterprise/__init__.py +3 -0
- src/enterprise/audit_log.py +182 -0
- src/enterprise/identity.py +90 -0
- src/enterprise/rbac.py +100 -0
- src/enterprise/team_config.py +125 -0
- src/enterprise/usage_analytics.py +261 -0
- src/exceptions.py +207 -0
- src/git_workflow.py +651 -0
- src/integrations/__init__.py +6 -0
- src/integrations/github_actions.py +106 -0
- src/integrations/mcp_server.py +333 -0
- src/integrations/sentry_integration.py +100 -0
- src/integrations/sentry_server.py +82 -0
- src/integrations/webhook_security.py +19 -0
- src/main.py +27 -0
- src/memory/__init__.py +0 -0
- src/memory/code_index.py +376 -0
- src/memory/compressor.py +378 -0
- src/memory/context_memory.py +135 -0
- src/memory/continuous_memory.py +234 -0
- src/memory/conventions.py +495 -0
- src/memory/db.py +1119 -0
- src/memory/document_index.py +205 -0
- src/memory/file_cache.py +128 -0
- src/memory/project_scanner.py +178 -0
- src/memory/session_store.py +201 -0
- src/models/__init__.py +0 -0
- src/models/client.py +715 -0
- src/models/definitions.py +459 -0
- src/models/router.py +418 -0
- src/models/schemas.py +389 -0
- src/permissions.py +294 -0
- src/remote/__init__.py +5 -0
- src/remote/command_filter.py +33 -0
- src/remote/models.py +31 -0
- src/remote/permission_handler.py +79 -0
- src/remote/phone_ui.py +48 -0
- src/remote/protocol.py +59 -0
- src/remote/qr.py +65 -0
- src/remote/server.py +586 -0
- src/remote/token_manager.py +61 -0
- src/remote/tunnel.py +212 -0
- src/repl.py +475 -0
- src/runtime/__init__.py +1 -0
- src/runtime/branch_farm.py +372 -0
- src/runtime/replay.py +351 -0
- src/sandbox/__init__.py +2 -0
- src/sandbox/hermetic.py +214 -0
- src/sandbox/policy.py +44 -0
- src/sdk/__init__.py +3 -0
- src/sdk/plugin_base.py +39 -0
- src/sdk/plugin_host.py +100 -0
- src/sdk/plugin_loader.py +101 -0
- src/security.py +409 -0
- src/server/__init__.py +7 -0
- src/server/bridge.py +427 -0
- src/server/bridge_cli.py +103 -0
- src/server/bridge_client.py +170 -0
- src/server/protocol_version.py +103 -0
- src/session/__init__.py +10 -0
- src/session/event_fanout.py +46 -0
- src/session/input_broker.py +38 -0
- src/session/permission_bridge.py +100 -0
- src/tools/__init__.py +160 -0
- src/tools/_atomic.py +72 -0
- src/tools/agent_tools.py +423 -0
- src/tools/ask_user_tool.py +83 -0
- src/tools/bash_tool.py +384 -0
- src/tools/browser_tool.py +352 -0
- src/tools/browser_tools.py +179 -0
- src/tools/dep_tools.py +210 -0
- src/tools/document_reader.py +167 -0
- src/tools/document_tool.py +240 -0
- src/tools/document_writer.py +171 -0
- src/tools/impact_tools.py +240 -0
- src/tools/playwright_tool.py +172 -0
- src/tools/quality_tools.py +366 -0
- src/tools/read_tools.py +318 -0
- src/tools/result_cache.py +157 -0
- src/tools/search_tools.py +310 -0
- src/tools/shell_tools.py +311 -0
- src/tools/write_tools.py +337 -0
- src/voice/__init__.py +25 -0
- src/voice/audio_capture.py +92 -0
- src/voice/audio_playback.py +68 -0
- src/voice/errors.py +14 -0
- src/voice/models.py +35 -0
- src/voice/providers.py +143 -0
- src/voice/vad.py +55 -0
- src/voice/voice_loop.py +156 -0
src/agent/daemon.py
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
"""Background job daemon — processes jobs from a SQLite queue.
|
|
2
|
+
|
|
3
|
+
Jobs are submitted via ``daemon.submit(job_type, payload)`` which writes
|
|
4
|
+
to the ``daemon_jobs`` table. The daemon thread claims one job at a time
|
|
5
|
+
(atomically via DB transaction) and writes the result back to the same row.
|
|
6
|
+
|
|
7
|
+
Falls back to legacy file-based inbox/outbox when no DB is available.
|
|
8
|
+
|
|
9
|
+
Job types:
|
|
10
|
+
index — run CodeIndex.build() on the project
|
|
11
|
+
compress — compress old transcript turns via SessionCompressor
|
|
12
|
+
scan — run ProjectScanner.scan() and update conventions
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import concurrent.futures
|
|
17
|
+
import http.server
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import signal
|
|
22
|
+
import socketserver
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from src.exceptions import DaemonUnavailableError
|
|
32
|
+
|
|
33
|
+
__all__ = ["BackgroundDaemon", "DaemonHealth", "DaemonJob", "InboxProcessor", "_resume_inflight_batches"]
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
_POLL_INTERVAL_SECS: float = 5.0
|
|
38
|
+
_JOB_TIMEOUT_SECS: float = 120.0
|
|
39
|
+
_OUTBOX_TTL_SECS: float = 3600.0
|
|
40
|
+
_MAX_RESTARTS: int = 3
|
|
41
|
+
_RESTART_WINDOW_SECS: float = 60.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Public types
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class DaemonHealth:
|
|
50
|
+
"""Snapshot of daemon thread health and queue depth."""
|
|
51
|
+
alive: bool
|
|
52
|
+
last_heartbeat: datetime | None
|
|
53
|
+
heartbeat_age_secs: float | None # None if never heartbeated
|
|
54
|
+
pending_jobs: int
|
|
55
|
+
failed_jobs: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DaemonJob:
|
|
59
|
+
"""A single daemon job (from DB row or legacy inbox file)."""
|
|
60
|
+
|
|
61
|
+
__slots__ = ("job_id", "type", "payload", "priority")
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
job_id: str,
|
|
66
|
+
type: str,
|
|
67
|
+
payload: dict[str, Any],
|
|
68
|
+
priority: int = 0,
|
|
69
|
+
) -> None:
|
|
70
|
+
self.job_id = job_id
|
|
71
|
+
self.type = type
|
|
72
|
+
self.payload = payload
|
|
73
|
+
self.priority = priority
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_db_row(cls, row: Any) -> "DaemonJob":
|
|
77
|
+
payload = json.loads(row["payload_json"]) if row["payload_json"] else {}
|
|
78
|
+
return cls(job_id=row["job_id"], type=row["job_type"], payload=payload)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_file(cls, path: Path) -> "DaemonJob":
|
|
82
|
+
"""Legacy: load from inbox JSON file."""
|
|
83
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
84
|
+
return cls(
|
|
85
|
+
job_id=data["job_id"],
|
|
86
|
+
type=data["type"],
|
|
87
|
+
payload=data.get("payload", {}),
|
|
88
|
+
priority=data.get("priority", 0),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# BackgroundDaemon
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
class BackgroundDaemon:
|
|
97
|
+
"""Threaded background daemon for async heavy tasks.
|
|
98
|
+
|
|
99
|
+
Uses a SQLite ``daemon_jobs`` table as the job queue when a DB is
|
|
100
|
+
provided (preferred). Falls back to file-based inbox/outbox polling
|
|
101
|
+
when ``db`` is None (legacy mode).
|
|
102
|
+
|
|
103
|
+
Usage::
|
|
104
|
+
|
|
105
|
+
daemon = BackgroundDaemon(db=db)
|
|
106
|
+
daemon.start()
|
|
107
|
+
job_id = daemon.submit("index", {"project_id": "..."})
|
|
108
|
+
result = daemon.get_result(job_id)
|
|
109
|
+
daemon.stop()
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
_HEARTBEAT_KEY = "daemon:heartbeat"
|
|
113
|
+
_HEARTBEAT_INTERVAL_SECS: float = 30.0
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
db_or_path: Any = None,
|
|
118
|
+
db: Any = None,
|
|
119
|
+
cfg: Any = None,
|
|
120
|
+
*,
|
|
121
|
+
memory_dir: Path | None = None,
|
|
122
|
+
max_restarts: int = 5,
|
|
123
|
+
pid_file: Path | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
# Support both:
|
|
126
|
+
# new: BackgroundDaemon(db=db)
|
|
127
|
+
# old: BackgroundDaemon(memory_dir, db, cfg)
|
|
128
|
+
if isinstance(db_or_path, Path):
|
|
129
|
+
_memory_dir: Path = db_or_path
|
|
130
|
+
_db = db
|
|
131
|
+
_cfg = cfg
|
|
132
|
+
else:
|
|
133
|
+
_db = db if db_or_path is None else db_or_path
|
|
134
|
+
_cfg = cfg
|
|
135
|
+
if memory_dir is not None:
|
|
136
|
+
_memory_dir = memory_dir
|
|
137
|
+
elif _cfg is not None and hasattr(_cfg, "context_memory_dir"):
|
|
138
|
+
_memory_dir = Path(_cfg.context_memory_dir)
|
|
139
|
+
elif hasattr(_db, "_db_path"):
|
|
140
|
+
_memory_dir = Path(_db._db_path).parent
|
|
141
|
+
else:
|
|
142
|
+
_memory_dir = Path(".context-memory")
|
|
143
|
+
|
|
144
|
+
self._memory_dir = _memory_dir
|
|
145
|
+
self._db = _db
|
|
146
|
+
self._cfg = _cfg
|
|
147
|
+
self._thread: threading.Thread | None = None
|
|
148
|
+
self._stop_event = threading.Event()
|
|
149
|
+
self._restart_timestamps: list[float] = []
|
|
150
|
+
self._restart_lock = threading.Lock()
|
|
151
|
+
self._result_callbacks: dict[str, Callable[[dict], None]] = {}
|
|
152
|
+
|
|
153
|
+
# Stability additions — graceful shutdown, crash recovery, health, PID
|
|
154
|
+
self._shutdown_event = threading.Event()
|
|
155
|
+
self._tasks_completed: int = 0
|
|
156
|
+
self._start_time: float = time.monotonic()
|
|
157
|
+
self._max_restarts: int = max_restarts
|
|
158
|
+
self._restart_count: int = 0
|
|
159
|
+
self._health_server: socketserver.TCPServer | None = None
|
|
160
|
+
self._pid_path: Path = (
|
|
161
|
+
pid_file if pid_file is not None
|
|
162
|
+
else _memory_dir / ".gdm" / "daemon.pid"
|
|
163
|
+
)
|
|
164
|
+
self._exit_fn: Callable[[int], None] = os._exit
|
|
165
|
+
|
|
166
|
+
# Legacy file inbox/outbox — only created when no DB is available
|
|
167
|
+
if self._db is None:
|
|
168
|
+
self._inbox = _memory_dir / "inbox"
|
|
169
|
+
self._outbox = _memory_dir / "outbox"
|
|
170
|
+
self._inbox.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
self._outbox.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
else:
|
|
173
|
+
self._inbox = _memory_dir / "inbox" # kept for attribute access compat
|
|
174
|
+
self._outbox = _memory_dir / "outbox"
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------
|
|
177
|
+
# Lifecycle
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def _check_restart_cap(self) -> bool:
|
|
181
|
+
"""Return True if a restart is allowed. Must be called under _restart_lock."""
|
|
182
|
+
now = time.monotonic()
|
|
183
|
+
self._restart_timestamps = [
|
|
184
|
+
t for t in self._restart_timestamps if now - t < _RESTART_WINDOW_SECS
|
|
185
|
+
]
|
|
186
|
+
if len(self._restart_timestamps) >= _MAX_RESTARTS:
|
|
187
|
+
log.error(
|
|
188
|
+
"Daemon restart cap hit (%d in %ds) — entering degraded mode",
|
|
189
|
+
_MAX_RESTARTS, _RESTART_WINDOW_SECS,
|
|
190
|
+
)
|
|
191
|
+
return False
|
|
192
|
+
self._restart_timestamps.append(now)
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
def start(self, *, write_pid: bool = True, health_port: int = 0) -> None:
|
|
196
|
+
"""Start the daemon thread."""
|
|
197
|
+
if self._thread and self._thread.is_alive():
|
|
198
|
+
return
|
|
199
|
+
self._stop_event.clear()
|
|
200
|
+
self._start_time = time.monotonic()
|
|
201
|
+
if write_pid:
|
|
202
|
+
self._write_pid()
|
|
203
|
+
if health_port > 0:
|
|
204
|
+
self.start_health_server(health_port)
|
|
205
|
+
if self._db is not None:
|
|
206
|
+
try:
|
|
207
|
+
_resume_inflight_batches(self._db)
|
|
208
|
+
except Exception as exc: # noqa: BLE001
|
|
209
|
+
log.warning("Could not resume in-flight batches on startup: %s", exc)
|
|
210
|
+
self._thread = threading.Thread(
|
|
211
|
+
target=self._run_with_restart,
|
|
212
|
+
name="gdm-daemon",
|
|
213
|
+
daemon=True,
|
|
214
|
+
)
|
|
215
|
+
self._thread.start()
|
|
216
|
+
log.debug("Background daemon started")
|
|
217
|
+
|
|
218
|
+
def stop(self, timeout: float = 3.0) -> None:
|
|
219
|
+
"""Signal the daemon to stop and wait for it."""
|
|
220
|
+
self._stop_event.set()
|
|
221
|
+
if self._thread:
|
|
222
|
+
self._thread.join(timeout=timeout)
|
|
223
|
+
if self._health_server is not None:
|
|
224
|
+
try:
|
|
225
|
+
self._health_server.shutdown()
|
|
226
|
+
except Exception: # noqa: BLE001
|
|
227
|
+
pass
|
|
228
|
+
self._health_server = None
|
|
229
|
+
self._remove_pid()
|
|
230
|
+
log.debug("Background daemon stopped")
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def is_running(self) -> bool:
|
|
234
|
+
"""True if the daemon thread is alive."""
|
|
235
|
+
return bool(self._thread and self._thread.is_alive())
|
|
236
|
+
|
|
237
|
+
def running(self) -> bool:
|
|
238
|
+
"""Method alias for is_running (for callers that prefer method syntax)."""
|
|
239
|
+
return self.is_running
|
|
240
|
+
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
# Stability: signal handling, crash recovery, health endpoint, PID
|
|
243
|
+
# ------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
def register_signal_handlers(self) -> None:
|
|
246
|
+
"""Register SIGTERM/SIGINT handlers that set _shutdown_event and stop the loop."""
|
|
247
|
+
def _handle(signum: int, frame: Any) -> None:
|
|
248
|
+
log.info("Received signal %d — requesting graceful shutdown", signum)
|
|
249
|
+
self._shutdown_event.set()
|
|
250
|
+
self._stop_event.set()
|
|
251
|
+
|
|
252
|
+
signal.signal(signal.SIGTERM, _handle)
|
|
253
|
+
signal.signal(signal.SIGINT, _handle)
|
|
254
|
+
|
|
255
|
+
def start_health_server(self, port: int = 7682) -> None:
|
|
256
|
+
"""Start a lightweight HTTP health endpoint in a daemon thread."""
|
|
257
|
+
daemon_ref = self
|
|
258
|
+
|
|
259
|
+
class _HealthHandler(http.server.BaseHTTPRequestHandler):
|
|
260
|
+
def do_GET(self) -> None:
|
|
261
|
+
if self.path == "/health":
|
|
262
|
+
uptime = time.monotonic() - daemon_ref._start_time
|
|
263
|
+
body = json.dumps({
|
|
264
|
+
"status": "ok",
|
|
265
|
+
"uptime_s": round(uptime, 2),
|
|
266
|
+
"tasks_completed": daemon_ref._tasks_completed,
|
|
267
|
+
}).encode()
|
|
268
|
+
self.send_response(200)
|
|
269
|
+
self.send_header("Content-Type", "application/json")
|
|
270
|
+
self.send_header("Content-Length", str(len(body)))
|
|
271
|
+
self.end_headers()
|
|
272
|
+
self.wfile.write(body)
|
|
273
|
+
else:
|
|
274
|
+
self.send_response(404)
|
|
275
|
+
self.end_headers()
|
|
276
|
+
|
|
277
|
+
def log_message(self, fmt: str, *args: Any) -> None: # silence HTTP access log
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
class _Server(socketserver.TCPServer):
|
|
281
|
+
allow_reuse_address = True
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
server = _Server(("127.0.0.1", port), _HealthHandler)
|
|
285
|
+
t = threading.Thread(
|
|
286
|
+
target=server.serve_forever,
|
|
287
|
+
name="gdm-health",
|
|
288
|
+
daemon=True,
|
|
289
|
+
)
|
|
290
|
+
t.start()
|
|
291
|
+
self._health_server = server
|
|
292
|
+
log.debug("Health endpoint listening on 127.0.0.1:%d", port)
|
|
293
|
+
except OSError as exc:
|
|
294
|
+
log.warning("Could not start health server on port %d: %s", port, exc)
|
|
295
|
+
|
|
296
|
+
def _run_with_restart(self) -> None:
|
|
297
|
+
"""Run _run_loop with exponential-backoff auto-restart on crash."""
|
|
298
|
+
backoff = 1.0
|
|
299
|
+
while not self._stop_event.is_set():
|
|
300
|
+
try:
|
|
301
|
+
self._run_loop()
|
|
302
|
+
return # clean exit via _stop_event
|
|
303
|
+
except KeyboardInterrupt:
|
|
304
|
+
return
|
|
305
|
+
except Exception as exc: # noqa: BLE001
|
|
306
|
+
if self._stop_event.is_set():
|
|
307
|
+
return
|
|
308
|
+
self._restart_count += 1
|
|
309
|
+
log.error(
|
|
310
|
+
"Daemon loop crashed (restart %d/%d): %s",
|
|
311
|
+
self._restart_count, self._max_restarts, exc,
|
|
312
|
+
exc_info=True,
|
|
313
|
+
)
|
|
314
|
+
if self._restart_count > self._max_restarts:
|
|
315
|
+
log.error(
|
|
316
|
+
"Daemon max restarts (%d) exceeded — exiting with code 1",
|
|
317
|
+
self._max_restarts,
|
|
318
|
+
)
|
|
319
|
+
self._exit_fn(1)
|
|
320
|
+
return # reached only when _exit_fn is mocked in tests
|
|
321
|
+
time.sleep(backoff)
|
|
322
|
+
backoff = min(backoff * 2, 30.0)
|
|
323
|
+
|
|
324
|
+
def _write_pid(self) -> None:
|
|
325
|
+
"""Write current process PID to _pid_path."""
|
|
326
|
+
try:
|
|
327
|
+
self._pid_path.parent.mkdir(parents=True, exist_ok=True)
|
|
328
|
+
self._pid_path.write_text(str(os.getpid()), encoding="utf-8")
|
|
329
|
+
log.debug("PID %d written to %s", os.getpid(), self._pid_path)
|
|
330
|
+
except Exception as exc: # noqa: BLE001
|
|
331
|
+
log.warning("Could not write PID file %s: %s", self._pid_path, exc)
|
|
332
|
+
|
|
333
|
+
def _remove_pid(self) -> None:
|
|
334
|
+
"""Remove the PID file if it exists."""
|
|
335
|
+
try:
|
|
336
|
+
if self._pid_path.exists():
|
|
337
|
+
self._pid_path.unlink()
|
|
338
|
+
log.debug("PID file removed: %s", self._pid_path)
|
|
339
|
+
except Exception as exc: # noqa: BLE001
|
|
340
|
+
log.warning("Could not remove PID file %s: %s", self._pid_path, exc)
|
|
341
|
+
|
|
342
|
+
# ------------------------------------------------------------------
|
|
343
|
+
# Health check
|
|
344
|
+
# ------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
def health_check(self) -> DaemonHealth:
|
|
347
|
+
"""Return a snapshot of thread health and queue depth."""
|
|
348
|
+
alive = self.is_running
|
|
349
|
+
last_hb = self._read_heartbeat()
|
|
350
|
+
age: float | None = None
|
|
351
|
+
if last_hb is not None:
|
|
352
|
+
age = (datetime.now(timezone.utc) - last_hb).total_seconds()
|
|
353
|
+
pending = self.pending_count()
|
|
354
|
+
failed = self._failed_count()
|
|
355
|
+
return DaemonHealth(
|
|
356
|
+
alive=alive,
|
|
357
|
+
last_heartbeat=last_hb,
|
|
358
|
+
heartbeat_age_secs=age,
|
|
359
|
+
pending_jobs=pending,
|
|
360
|
+
failed_jobs=failed,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
def _write_heartbeat(self) -> None:
|
|
364
|
+
if self._db is None:
|
|
365
|
+
return
|
|
366
|
+
try:
|
|
367
|
+
self._db.execute(
|
|
368
|
+
"INSERT OR REPLACE INTO spinner_state (key, value) VALUES (?, ?)",
|
|
369
|
+
(self._HEARTBEAT_KEY, datetime.now(timezone.utc).isoformat()),
|
|
370
|
+
)
|
|
371
|
+
except Exception as exc: # noqa: BLE001
|
|
372
|
+
log.debug("Heartbeat write failed: %s", exc)
|
|
373
|
+
|
|
374
|
+
def _read_heartbeat(self) -> datetime | None:
|
|
375
|
+
if self._db is None:
|
|
376
|
+
return None
|
|
377
|
+
try:
|
|
378
|
+
row = self._db.execute_one(
|
|
379
|
+
"SELECT value FROM spinner_state WHERE key=?", (self._HEARTBEAT_KEY,)
|
|
380
|
+
)
|
|
381
|
+
return datetime.fromisoformat(row["value"]) if row else None
|
|
382
|
+
except Exception as exc: # noqa: BLE001
|
|
383
|
+
log.debug("Heartbeat read failed: %s", exc)
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
# ------------------------------------------------------------------
|
|
387
|
+
# Job submission
|
|
388
|
+
# ------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
def submit(self, job_type: str, payload: dict[str, Any] | None = None, *, priority: int = 0) -> str:
|
|
391
|
+
"""Submit a job. Uses DB queue when available, else legacy file inbox.
|
|
392
|
+
|
|
393
|
+
Checks restart cap before enqueuing (fails fast if degraded) and starts
|
|
394
|
+
the daemon thread after enqueuing so jobs are visible on the first poll.
|
|
395
|
+
Returns job_id.
|
|
396
|
+
"""
|
|
397
|
+
# Pre-check restart cap BEFORE enqueue — reject early if degraded.
|
|
398
|
+
# We resolve the start decision here but defer the actual start()
|
|
399
|
+
# until after enqueue so the daemon thread sees the job on its first poll.
|
|
400
|
+
_should_start = False
|
|
401
|
+
if not self.is_running:
|
|
402
|
+
with self._restart_lock:
|
|
403
|
+
if not self.is_running:
|
|
404
|
+
if self._check_restart_cap():
|
|
405
|
+
_should_start = True
|
|
406
|
+
else:
|
|
407
|
+
raise DaemonUnavailableError(
|
|
408
|
+
"Daemon crashed too frequently. Check logs."
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
if self._db is not None:
|
|
412
|
+
job_id = self._db.daemon_job_submit(job_type, payload or {})
|
|
413
|
+
log.debug("Daemon job submitted to DB: %s (type=%s)", job_id, job_type)
|
|
414
|
+
else:
|
|
415
|
+
import uuid
|
|
416
|
+
job_id = str(uuid.uuid4())[:8]
|
|
417
|
+
job = {
|
|
418
|
+
"job_id": job_id,
|
|
419
|
+
"type": job_type,
|
|
420
|
+
"payload": payload or {},
|
|
421
|
+
"priority": priority,
|
|
422
|
+
}
|
|
423
|
+
path = self._inbox / f"{job_id}.json"
|
|
424
|
+
path.write_text(json.dumps(job), encoding="utf-8")
|
|
425
|
+
log.debug("Daemon job submitted to file inbox: %s (type=%s)", job_id, job_type)
|
|
426
|
+
|
|
427
|
+
if _should_start:
|
|
428
|
+
log.warning("Daemon thread was not running — starting")
|
|
429
|
+
self.start()
|
|
430
|
+
|
|
431
|
+
return job_id
|
|
432
|
+
|
|
433
|
+
def get_result(self, job_id: str) -> dict[str, Any] | None:
|
|
434
|
+
"""Return result for a completed job, or None if not yet done."""
|
|
435
|
+
if self._db is not None:
|
|
436
|
+
return self._db.daemon_job_result(job_id)
|
|
437
|
+
# Legacy file fallback
|
|
438
|
+
path = self._outbox / f"{job_id}.json"
|
|
439
|
+
if not path.exists():
|
|
440
|
+
return None
|
|
441
|
+
try:
|
|
442
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
443
|
+
except Exception: # noqa: BLE001
|
|
444
|
+
return None
|
|
445
|
+
|
|
446
|
+
def on_result(self, job_id: str, callback: Callable[[dict], None]) -> None:
|
|
447
|
+
"""Register a callback to be fired (in a daemon thread) when job_id completes."""
|
|
448
|
+
self._result_callbacks[job_id] = callback
|
|
449
|
+
|
|
450
|
+
def _fire_callback(self, job_id: str, result: dict[str, Any]) -> None:
|
|
451
|
+
cb = self._result_callbacks.pop(job_id, None)
|
|
452
|
+
if cb is not None:
|
|
453
|
+
threading.Thread(target=cb, args=(result,), daemon=True).start()
|
|
454
|
+
|
|
455
|
+
def pending_count(self) -> int:
|
|
456
|
+
"""Number of jobs waiting to be processed."""
|
|
457
|
+
if self._db is not None:
|
|
458
|
+
return self._db.daemon_job_pending_count()
|
|
459
|
+
return len(list(self._inbox.glob("*.json")))
|
|
460
|
+
|
|
461
|
+
def _failed_count(self) -> int:
|
|
462
|
+
if self._db is not None:
|
|
463
|
+
return self._db.daemon_job_failed_count()
|
|
464
|
+
return 0
|
|
465
|
+
|
|
466
|
+
# ------------------------------------------------------------------
|
|
467
|
+
# Main loop
|
|
468
|
+
# ------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
def _run_loop(self) -> None:
|
|
471
|
+
"""Daemon main loop — claim jobs from DB (or file inbox) and process them."""
|
|
472
|
+
last_hb_time = 0.0
|
|
473
|
+
while not self._stop_event.is_set():
|
|
474
|
+
# Write heartbeat every _HEARTBEAT_INTERVAL_SECS
|
|
475
|
+
now = time.monotonic()
|
|
476
|
+
if now - last_hb_time >= self._HEARTBEAT_INTERVAL_SECS:
|
|
477
|
+
self._write_heartbeat()
|
|
478
|
+
last_hb_time = now
|
|
479
|
+
|
|
480
|
+
if self._db is not None:
|
|
481
|
+
row = self._db.daemon_job_claim()
|
|
482
|
+
if row is not None:
|
|
483
|
+
self._process_db_job(row)
|
|
484
|
+
continue # Check immediately for more jobs
|
|
485
|
+
else:
|
|
486
|
+
# Legacy file polling with priority sort
|
|
487
|
+
inbox_files = list(self._inbox.glob("*.json"))
|
|
488
|
+
if inbox_files:
|
|
489
|
+
def _priority_key(f: Path) -> int:
|
|
490
|
+
try:
|
|
491
|
+
return json.loads(f.read_text(encoding="utf-8")).get("priority", 0)
|
|
492
|
+
except Exception: # noqa: BLE001
|
|
493
|
+
return 0
|
|
494
|
+
jobs = sorted(inbox_files, key=_priority_key, reverse=True)
|
|
495
|
+
self._process_file_job(jobs[0])
|
|
496
|
+
# Outbox TTL cleanup
|
|
497
|
+
_now = time.time()
|
|
498
|
+
for f in self._outbox.glob("*.json"):
|
|
499
|
+
try:
|
|
500
|
+
if _now - f.stat().st_mtime > _OUTBOX_TTL_SECS:
|
|
501
|
+
f.unlink(missing_ok=True)
|
|
502
|
+
except Exception: # noqa: BLE001
|
|
503
|
+
pass
|
|
504
|
+
continue
|
|
505
|
+
|
|
506
|
+
self._stop_event.wait(timeout=_POLL_INTERVAL_SECS)
|
|
507
|
+
|
|
508
|
+
# Poll sentry inbox non-fatally
|
|
509
|
+
try:
|
|
510
|
+
InboxProcessor(str(self._memory_dir / "inbox")).poll()
|
|
511
|
+
except Exception: # noqa: BLE001
|
|
512
|
+
pass
|
|
513
|
+
|
|
514
|
+
def _process_db_job(self, row: Any) -> None:
|
|
515
|
+
"""Execute a job claimed from the DB and write result back."""
|
|
516
|
+
job = DaemonJob.from_db_row(row)
|
|
517
|
+
log.info("Daemon: running DB job %s (type=%s)", job.job_id, job.type)
|
|
518
|
+
start = time.monotonic()
|
|
519
|
+
executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
|
520
|
+
future = executor.submit(self._dispatch, job)
|
|
521
|
+
try:
|
|
522
|
+
result = future.result(timeout=_JOB_TIMEOUT_SECS)
|
|
523
|
+
except concurrent.futures.TimeoutError:
|
|
524
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
525
|
+
log.error(
|
|
526
|
+
"Daemon: DB job %s (%s) timed out after %.0fs",
|
|
527
|
+
job.job_id, job.type, _JOB_TIMEOUT_SECS,
|
|
528
|
+
)
|
|
529
|
+
try:
|
|
530
|
+
self._db.daemon_job_fail(job.job_id, f"timeout after {_JOB_TIMEOUT_SECS}s")
|
|
531
|
+
except Exception as db_exc: # noqa: BLE001
|
|
532
|
+
log.warning("daemon_job_fail write failed: %s", db_exc)
|
|
533
|
+
self._fire_callback(job.job_id, {"error": f"timeout after {_JOB_TIMEOUT_SECS}s"})
|
|
534
|
+
return
|
|
535
|
+
except Exception as exc: # noqa: BLE001
|
|
536
|
+
executor.shutdown(wait=False)
|
|
537
|
+
log.exception("Daemon: DB job %s failed: %s", job.job_id, exc)
|
|
538
|
+
try:
|
|
539
|
+
self._db.daemon_job_fail(job.job_id, str(exc))
|
|
540
|
+
except Exception as db_exc: # noqa: BLE001
|
|
541
|
+
log.warning("daemon_job_fail write failed: %s", db_exc)
|
|
542
|
+
self._fire_callback(job.job_id, {"error": str(exc)})
|
|
543
|
+
return
|
|
544
|
+
executor.shutdown(wait=False)
|
|
545
|
+
|
|
546
|
+
elapsed = time.monotonic() - start
|
|
547
|
+
result.setdefault("elapsed_secs", round(elapsed, 2))
|
|
548
|
+
result.setdefault("job_id", job.job_id)
|
|
549
|
+
try:
|
|
550
|
+
self._db.daemon_job_complete(job.job_id, result)
|
|
551
|
+
except Exception as db_exc: # noqa: BLE001
|
|
552
|
+
log.warning("daemon_job_complete write failed: %s", db_exc)
|
|
553
|
+
log.info("Daemon: DB job %s done in %.1fs", job.job_id, elapsed)
|
|
554
|
+
self._tasks_completed += 1
|
|
555
|
+
self._fire_callback(job.job_id, result)
|
|
556
|
+
|
|
557
|
+
def _process_file_job(self, inbox_path: Path) -> None:
|
|
558
|
+
"""Legacy: load, execute, and archive a single file job."""
|
|
559
|
+
try:
|
|
560
|
+
job = DaemonJob.from_file(inbox_path)
|
|
561
|
+
except Exception as exc: # noqa: BLE001
|
|
562
|
+
log.warning("Daemon: malformed job %s: %s", inbox_path.name, exc)
|
|
563
|
+
inbox_path.unlink(missing_ok=True)
|
|
564
|
+
return
|
|
565
|
+
|
|
566
|
+
log.info("Daemon: running file job %s (type=%s)", job.job_id, job.type)
|
|
567
|
+
start = time.monotonic()
|
|
568
|
+
executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
|
569
|
+
future = executor.submit(self._dispatch, job)
|
|
570
|
+
try:
|
|
571
|
+
result = future.result(timeout=_JOB_TIMEOUT_SECS)
|
|
572
|
+
except concurrent.futures.TimeoutError:
|
|
573
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
574
|
+
log.error(
|
|
575
|
+
"Daemon: file job %s (%s) timed out after %.0fs",
|
|
576
|
+
job.job_id, job.type, _JOB_TIMEOUT_SECS,
|
|
577
|
+
)
|
|
578
|
+
result = {"ok": False, "error": f"timeout after {_JOB_TIMEOUT_SECS}s"}
|
|
579
|
+
except Exception as exc: # noqa: BLE001
|
|
580
|
+
executor.shutdown(wait=False)
|
|
581
|
+
log.exception("Daemon: file job %s failed: %s", job.job_id, exc)
|
|
582
|
+
result = {"ok": False, "error": str(exc)}
|
|
583
|
+
else:
|
|
584
|
+
executor.shutdown(wait=False)
|
|
585
|
+
|
|
586
|
+
elapsed = time.monotonic() - start
|
|
587
|
+
result.setdefault("elapsed_secs", round(elapsed, 2))
|
|
588
|
+
result.setdefault("job_id", job.job_id)
|
|
589
|
+
out = self._outbox / f"{job.job_id}.json"
|
|
590
|
+
out.write_text(json.dumps(result), encoding="utf-8")
|
|
591
|
+
inbox_path.unlink(missing_ok=True)
|
|
592
|
+
log.info("Daemon: file job %s done in %.1fs", job.job_id, elapsed)
|
|
593
|
+
self._tasks_completed += 1
|
|
594
|
+
self._fire_callback(job.job_id, result)
|
|
595
|
+
|
|
596
|
+
def _dispatch(self, job: DaemonJob) -> dict[str, Any]:
|
|
597
|
+
"""Route job to the right handler. Returns result dict."""
|
|
598
|
+
if job.type == "index":
|
|
599
|
+
return self._handle_index(job.payload)
|
|
600
|
+
if job.type == "compress":
|
|
601
|
+
return self._handle_compress(job.payload)
|
|
602
|
+
if job.type == "scan":
|
|
603
|
+
return self._handle_scan(job.payload)
|
|
604
|
+
return {"ok": False, "error": f"Unknown job type: {job.type!r}"}
|
|
605
|
+
|
|
606
|
+
# ------------------------------------------------------------------
|
|
607
|
+
# Handlers
|
|
608
|
+
# ------------------------------------------------------------------
|
|
609
|
+
|
|
610
|
+
def _handle_index(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
611
|
+
from src.memory.code_index import CodeIndex
|
|
612
|
+
project_id = payload.get("project_id", "")
|
|
613
|
+
root = Path(payload.get("root", "."))
|
|
614
|
+
index = CodeIndex(self._db, project_id, root)
|
|
615
|
+
count = index.build(force=payload.get("force", False))
|
|
616
|
+
return {"ok": True, "files_indexed": count}
|
|
617
|
+
|
|
618
|
+
def _handle_compress(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
619
|
+
try:
|
|
620
|
+
from src.memory.compressor import SessionCompressor
|
|
621
|
+
session_id = payload.get("session_id", "")
|
|
622
|
+
compressor = SessionCompressor(self._cfg)
|
|
623
|
+
digest = compressor.compress_session(session_id)
|
|
624
|
+
return {"ok": True, "digest": digest}
|
|
625
|
+
except ImportError:
|
|
626
|
+
return {"ok": False, "error": "compressor not available"}
|
|
627
|
+
|
|
628
|
+
def _handle_scan(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
629
|
+
try:
|
|
630
|
+
from src.memory.project_scanner import ProjectScanner
|
|
631
|
+
project_id = payload.get("project_id", "")
|
|
632
|
+
root = Path(payload.get("root", "."))
|
|
633
|
+
scanner = ProjectScanner(self._db)
|
|
634
|
+
info = scanner.scan(root)
|
|
635
|
+
return {"ok": True, "tech_stack": info.tech_stack if hasattr(info, "tech_stack") else {}}
|
|
636
|
+
except ImportError:
|
|
637
|
+
return {"ok": False, "error": "project_scanner not available"}
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
# ---------------------------------------------------------------------------
|
|
641
|
+
# Sentry inbox processor
|
|
642
|
+
# ---------------------------------------------------------------------------
|
|
643
|
+
|
|
644
|
+
class InboxProcessor:
|
|
645
|
+
"""Polls .context-memory/inbox/ for sentry-*.json task files."""
|
|
646
|
+
|
|
647
|
+
def __init__(self, inbox_dir: str = ".context-memory/inbox"):
|
|
648
|
+
self._inbox = Path(inbox_dir)
|
|
649
|
+
|
|
650
|
+
def poll(self) -> list[dict]:
|
|
651
|
+
"""Return list of pending task dicts (does not consume them)."""
|
|
652
|
+
if not self._inbox.exists():
|
|
653
|
+
return []
|
|
654
|
+
tasks = []
|
|
655
|
+
for f in sorted(self._inbox.glob("sentry-*.json")):
|
|
656
|
+
try:
|
|
657
|
+
tasks.append(json.loads(f.read_text()))
|
|
658
|
+
except Exception: # noqa: BLE001
|
|
659
|
+
pass
|
|
660
|
+
return tasks
|
|
661
|
+
|
|
662
|
+
def consume(self, issue_id: str) -> None:
|
|
663
|
+
"""Remove inbox file after dispatch."""
|
|
664
|
+
f = self._inbox / f"sentry-{issue_id}.json"
|
|
665
|
+
if f.exists():
|
|
666
|
+
f.unlink()
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
# ---------------------------------------------------------------------------
|
|
670
|
+
# Batch crash recovery
|
|
671
|
+
# ---------------------------------------------------------------------------
|
|
672
|
+
|
|
673
|
+
def _resume_inflight_batches(db_conn: Any) -> list[dict]:
|
|
674
|
+
"""On startup, re-attach to any submitted/polling batch jobs."""
|
|
675
|
+
rows = db_conn.execute(
|
|
676
|
+
"SELECT id, provider, batch_id, job_type FROM batch_jobs "
|
|
677
|
+
"WHERE status IN ('submitted', 'polling')"
|
|
678
|
+
).fetchall()
|
|
679
|
+
if rows:
|
|
680
|
+
log.info("Resuming %d in-flight batch jobs", len(rows))
|
|
681
|
+
return [dict(zip(["id", "provider", "batch_id", "job_type"], r)) for r in rows]
|