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.
Files changed (131) hide show
  1. gdmcode-0.1.0.dist-info/METADATA +240 -0
  2. gdmcode-0.1.0.dist-info/RECORD +131 -0
  3. gdmcode-0.1.0.dist-info/WHEEL +4 -0
  4. gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. src/__init__.py +1 -0
  6. src/_internal/__init__.py +0 -0
  7. src/_internal/constants.py +244 -0
  8. src/_internal/domain_skills.py +339 -0
  9. src/agent/__init__.py +0 -0
  10. src/agent/commit_classifier.py +91 -0
  11. src/agent/context_budget.py +391 -0
  12. src/agent/daemon.py +681 -0
  13. src/agent/dag_validator.py +153 -0
  14. src/agent/debug_loop.py +473 -0
  15. src/agent/impact_analyzer.py +149 -0
  16. src/agent/impact_graph.py +117 -0
  17. src/agent/loop.py +1410 -0
  18. src/agent/orchestrator.py +141 -0
  19. src/agent/regression_guard.py +251 -0
  20. src/agent/review_gate.py +648 -0
  21. src/agent/risk_scorer.py +169 -0
  22. src/agent/self_healing.py +145 -0
  23. src/agent/smart_test_selector.py +89 -0
  24. src/agent/system_prompt.py +226 -0
  25. src/agent/task_tracker.py +320 -0
  26. src/agent/test_validator.py +210 -0
  27. src/agent/tool_orchestrator.py +402 -0
  28. src/agent/transcript.py +230 -0
  29. src/agent/verification_loop.py +133 -0
  30. src/agent/work_director.py +136 -0
  31. src/agent/worktree_manager.py +53 -0
  32. src/artifacts/__init__.py +16 -0
  33. src/artifacts/artifact_store.py +456 -0
  34. src/artifacts/verification_graph.py +75 -0
  35. src/auth.py +411 -0
  36. src/cli.py +1290 -0
  37. src/commands.py +1398 -0
  38. src/config.py +762 -0
  39. src/cost_tracker.py +348 -0
  40. src/db/__init__.py +4 -0
  41. src/db/migrations.py +337 -0
  42. src/enterprise/__init__.py +3 -0
  43. src/enterprise/audit_log.py +182 -0
  44. src/enterprise/identity.py +90 -0
  45. src/enterprise/rbac.py +100 -0
  46. src/enterprise/team_config.py +125 -0
  47. src/enterprise/usage_analytics.py +261 -0
  48. src/exceptions.py +207 -0
  49. src/git_workflow.py +651 -0
  50. src/integrations/__init__.py +6 -0
  51. src/integrations/github_actions.py +106 -0
  52. src/integrations/mcp_server.py +333 -0
  53. src/integrations/sentry_integration.py +100 -0
  54. src/integrations/sentry_server.py +82 -0
  55. src/integrations/webhook_security.py +19 -0
  56. src/main.py +27 -0
  57. src/memory/__init__.py +0 -0
  58. src/memory/code_index.py +376 -0
  59. src/memory/compressor.py +378 -0
  60. src/memory/context_memory.py +135 -0
  61. src/memory/continuous_memory.py +234 -0
  62. src/memory/conventions.py +495 -0
  63. src/memory/db.py +1119 -0
  64. src/memory/document_index.py +205 -0
  65. src/memory/file_cache.py +128 -0
  66. src/memory/project_scanner.py +178 -0
  67. src/memory/session_store.py +201 -0
  68. src/models/__init__.py +0 -0
  69. src/models/client.py +715 -0
  70. src/models/definitions.py +459 -0
  71. src/models/router.py +418 -0
  72. src/models/schemas.py +389 -0
  73. src/permissions.py +294 -0
  74. src/remote/__init__.py +5 -0
  75. src/remote/command_filter.py +33 -0
  76. src/remote/models.py +31 -0
  77. src/remote/permission_handler.py +79 -0
  78. src/remote/phone_ui.py +48 -0
  79. src/remote/protocol.py +59 -0
  80. src/remote/qr.py +65 -0
  81. src/remote/server.py +586 -0
  82. src/remote/token_manager.py +61 -0
  83. src/remote/tunnel.py +212 -0
  84. src/repl.py +475 -0
  85. src/runtime/__init__.py +1 -0
  86. src/runtime/branch_farm.py +372 -0
  87. src/runtime/replay.py +351 -0
  88. src/sandbox/__init__.py +2 -0
  89. src/sandbox/hermetic.py +214 -0
  90. src/sandbox/policy.py +44 -0
  91. src/sdk/__init__.py +3 -0
  92. src/sdk/plugin_base.py +39 -0
  93. src/sdk/plugin_host.py +100 -0
  94. src/sdk/plugin_loader.py +101 -0
  95. src/security.py +409 -0
  96. src/server/__init__.py +7 -0
  97. src/server/bridge.py +427 -0
  98. src/server/bridge_cli.py +103 -0
  99. src/server/bridge_client.py +170 -0
  100. src/server/protocol_version.py +103 -0
  101. src/session/__init__.py +10 -0
  102. src/session/event_fanout.py +46 -0
  103. src/session/input_broker.py +38 -0
  104. src/session/permission_bridge.py +100 -0
  105. src/tools/__init__.py +160 -0
  106. src/tools/_atomic.py +72 -0
  107. src/tools/agent_tools.py +423 -0
  108. src/tools/ask_user_tool.py +83 -0
  109. src/tools/bash_tool.py +384 -0
  110. src/tools/browser_tool.py +352 -0
  111. src/tools/browser_tools.py +179 -0
  112. src/tools/dep_tools.py +210 -0
  113. src/tools/document_reader.py +167 -0
  114. src/tools/document_tool.py +240 -0
  115. src/tools/document_writer.py +171 -0
  116. src/tools/impact_tools.py +240 -0
  117. src/tools/playwright_tool.py +172 -0
  118. src/tools/quality_tools.py +366 -0
  119. src/tools/read_tools.py +318 -0
  120. src/tools/result_cache.py +157 -0
  121. src/tools/search_tools.py +310 -0
  122. src/tools/shell_tools.py +311 -0
  123. src/tools/write_tools.py +337 -0
  124. src/voice/__init__.py +25 -0
  125. src/voice/audio_capture.py +92 -0
  126. src/voice/audio_playback.py +68 -0
  127. src/voice/errors.py +14 -0
  128. src/voice/models.py +35 -0
  129. src/voice/providers.py +143 -0
  130. src/voice/vad.py +55 -0
  131. 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]