aline-ai 0.7.2__py3-none-any.whl → 0.7.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.
- {aline_ai-0.7.2.dist-info → aline_ai-0.7.4.dist-info}/METADATA +1 -1
- {aline_ai-0.7.2.dist-info → aline_ai-0.7.4.dist-info}/RECORD +32 -27
- realign/__init__.py +1 -1
- realign/adapters/codex.py +30 -2
- realign/claude_hooks/stop_hook.py +176 -21
- realign/codex_home.py +71 -0
- realign/codex_hooks/__init__.py +16 -0
- realign/codex_hooks/notify_hook.py +511 -0
- realign/codex_hooks/notify_hook_installer.py +247 -0
- realign/commands/doctor.py +125 -0
- realign/commands/export_shares.py +188 -65
- realign/commands/import_shares.py +30 -10
- realign/commands/init.py +16 -0
- realign/commands/sync_agent.py +274 -44
- realign/commit_pipeline.py +1024 -0
- realign/config.py +3 -11
- realign/dashboard/app.py +151 -2
- realign/dashboard/diagnostics.py +274 -0
- realign/dashboard/screens/create_agent.py +2 -1
- realign/dashboard/screens/create_agent_info.py +40 -77
- realign/dashboard/tmux_manager.py +348 -33
- realign/dashboard/widgets/agents_panel.py +942 -314
- realign/dashboard/widgets/config_panel.py +34 -121
- realign/dashboard/widgets/header.py +1 -1
- realign/db/sqlite_db.py +59 -1
- realign/logging_config.py +51 -6
- realign/watcher_core.py +742 -393
- realign/worker_core.py +206 -15
- {aline_ai-0.7.2.dist-info → aline_ai-0.7.4.dist-info}/WHEEL +0 -0
- {aline_ai-0.7.2.dist-info → aline_ai-0.7.4.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.7.2.dist-info → aline_ai-0.7.4.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.7.2.dist-info → aline_ai-0.7.4.dist-info}/top_level.txt +0 -0
realign/watcher_core.py
CHANGED
|
@@ -10,6 +10,7 @@ import os
|
|
|
10
10
|
import subprocess
|
|
11
11
|
import sys
|
|
12
12
|
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
from typing import Any, Callable, Optional, Dict, Literal
|
|
15
16
|
from datetime import datetime
|
|
@@ -26,6 +27,17 @@ logger = setup_logger("realign.watcher_core", "watcher_core.log")
|
|
|
26
27
|
SessionType = Literal["claude", "codex", "gemini", "unknown"]
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class StartupScanReport:
|
|
32
|
+
prev_paths: int
|
|
33
|
+
prev_missing: int
|
|
34
|
+
prev_changed: int
|
|
35
|
+
scan_paths: int
|
|
36
|
+
scan_new: int
|
|
37
|
+
scan_changed: int
|
|
38
|
+
candidates: int
|
|
39
|
+
|
|
40
|
+
|
|
29
41
|
def is_path_blacklisted(project_path: Path) -> bool:
|
|
30
42
|
"""
|
|
31
43
|
Check if a project path is blacklisted for auto-init.
|
|
@@ -211,21 +223,20 @@ class DialogueWatcher:
|
|
|
211
223
|
self.last_commit_times: Dict[str, float] = {} # Track last commit time per project
|
|
212
224
|
self.last_session_sizes: Dict[str, int] = {} # Track file sizes
|
|
213
225
|
self.last_stop_reason_counts: Dict[str, int] = {} # Track stop_reason counts per session
|
|
214
|
-
self.last_session_mtimes: Dict[str, float] =
|
|
215
|
-
{}
|
|
216
|
-
) # Track last mtime of session files for idle detection
|
|
217
|
-
self.last_final_commit_times: Dict[str, float] = (
|
|
218
|
-
{}
|
|
219
|
-
) # Track when we last tried final commit per session
|
|
226
|
+
self.last_session_mtimes: Dict[str, float] = {} # Track last mtime of session files
|
|
220
227
|
self.min_commit_interval = 5.0 # Minimum 5 seconds between commits (cooldown)
|
|
221
228
|
self.debounce_delay = 10.0 # Wait 10 seconds after file change to ensure turn is complete (increased from 2.0 to handle streaming responses)
|
|
222
|
-
self.final_commit_idle_timeout = 300.0 # 5 minutes idle to trigger final commit
|
|
223
229
|
self.running = False
|
|
224
230
|
self.pending_commit_task: Optional[asyncio.Task] = None
|
|
225
231
|
self._pending_changed_files: set[str] = (
|
|
226
232
|
set()
|
|
227
233
|
) # Accumulate changed files instead of cancelling
|
|
228
234
|
|
|
235
|
+
# Stop-hook signals: retry/backoff map so transient enqueue failures don't drop turns.
|
|
236
|
+
# Key: absolute signal file path.
|
|
237
|
+
self._stop_signal_retry_after: Dict[str, float] = {}
|
|
238
|
+
self._stop_signal_failures: Dict[str, int] = {}
|
|
239
|
+
|
|
229
240
|
# Trigger support for pluggable turn detection
|
|
230
241
|
from .triggers.registry import get_global_registry
|
|
231
242
|
|
|
@@ -250,7 +261,7 @@ class DialogueWatcher:
|
|
|
250
261
|
self._session_list_last_scan: float = 0.0
|
|
251
262
|
self._session_list_scan_interval: float = 30.0
|
|
252
263
|
|
|
253
|
-
# Layer 2: Per-cycle sizes stash (shared between check_for_changes and
|
|
264
|
+
# Layer 2: Per-cycle sizes stash (shared between check_for_changes and turn-count cache)
|
|
254
265
|
self._last_cycle_sizes: Dict[str, int] = {}
|
|
255
266
|
|
|
256
267
|
# Layer 3: Turn count cache (avoid re-parsing JSONL for unchanged files)
|
|
@@ -264,6 +275,199 @@ class DialogueWatcher:
|
|
|
264
275
|
self.user_prompt_signal_dir = self.signal_dir / "user_prompt_submit"
|
|
265
276
|
self.user_prompt_signal_dir.mkdir(parents=True, exist_ok=True)
|
|
266
277
|
|
|
278
|
+
# Polling control:
|
|
279
|
+
# - When the Stop hook is available, periodic session polling/scanning is unnecessary.
|
|
280
|
+
# - The watcher remains responsible for processing fallback .signal files.
|
|
281
|
+
self._polling_enabled: bool = True
|
|
282
|
+
|
|
283
|
+
# Startup scan enqueue priority (lower than realtime stop-hook/signal work).
|
|
284
|
+
self._startup_scan_priority: int = 5
|
|
285
|
+
|
|
286
|
+
# Codex notify-hook fallback:
|
|
287
|
+
# Some Codex CLIs don't support (or don't run) the Rust notify hook. When the watcher is
|
|
288
|
+
# in signal-driven mode (polling disabled), those Codex sessions would otherwise never be
|
|
289
|
+
# discovered/processed. We keep a lightweight Codex-only polling loop for that case.
|
|
290
|
+
self._codex_notify_supported: bool | None = None
|
|
291
|
+
self._codex_fallback_poll_enabled: bool = False
|
|
292
|
+
self._codex_fallback_poll_last_scan: float = 0.0
|
|
293
|
+
self._codex_fallback_poll_interval_seconds: float = 2.0
|
|
294
|
+
self._codex_fallback_last_sizes: Dict[str, int] = {}
|
|
295
|
+
self._codex_fallback_last_mtimes: Dict[str, float] = {}
|
|
296
|
+
self._codex_notify_hook_cache: dict[str, tuple[float, bool]] = {}
|
|
297
|
+
self._codex_notify_hook_cache_ttl_seconds: float = 30.0
|
|
298
|
+
try:
|
|
299
|
+
raw = os.environ.get("ALINE_CODEX_POLL_INTERVAL_SECONDS", "").strip()
|
|
300
|
+
if raw:
|
|
301
|
+
self._codex_fallback_poll_interval_seconds = max(0.5, float(raw))
|
|
302
|
+
except Exception:
|
|
303
|
+
self._codex_fallback_poll_interval_seconds = 2.0
|
|
304
|
+
|
|
305
|
+
def _codex_notify_hook_installed_for_session_file(self, session_file: Path) -> bool | None:
|
|
306
|
+
"""Best-effort detect whether Aline's Codex notify hook is installed for this session."""
|
|
307
|
+
try:
|
|
308
|
+
from .codex_hooks.notify_hook_installer import ALINE_HOOK_MARKER
|
|
309
|
+
except Exception:
|
|
310
|
+
ALINE_HOOK_MARKER = "aline-codex-notify-hook"
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
from .codex_home import codex_home_from_session_file
|
|
314
|
+
except Exception:
|
|
315
|
+
codex_home_from_session_file = None # type: ignore[assignment]
|
|
316
|
+
|
|
317
|
+
codex_home: Path | None = None
|
|
318
|
+
if codex_home_from_session_file is not None:
|
|
319
|
+
try:
|
|
320
|
+
codex_home = codex_home_from_session_file(session_file)
|
|
321
|
+
except Exception:
|
|
322
|
+
codex_home = None
|
|
323
|
+
if codex_home is None:
|
|
324
|
+
try:
|
|
325
|
+
p = session_file.resolve()
|
|
326
|
+
for parent in p.parents:
|
|
327
|
+
if parent.name == "sessions":
|
|
328
|
+
codex_home = parent.parent
|
|
329
|
+
break
|
|
330
|
+
except Exception:
|
|
331
|
+
codex_home = None
|
|
332
|
+
|
|
333
|
+
if codex_home is None:
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
now = time.time()
|
|
337
|
+
key = str(codex_home)
|
|
338
|
+
cached = self._codex_notify_hook_cache.get(key)
|
|
339
|
+
if cached and (now - float(cached[0] or 0.0)) < float(self._codex_notify_hook_cache_ttl_seconds):
|
|
340
|
+
return bool(cached[1])
|
|
341
|
+
|
|
342
|
+
installed = False
|
|
343
|
+
try:
|
|
344
|
+
toml_path = codex_home / "config.toml"
|
|
345
|
+
if not toml_path.exists():
|
|
346
|
+
installed = False
|
|
347
|
+
else:
|
|
348
|
+
raw = toml_path.read_text(encoding="utf-8", errors="ignore")
|
|
349
|
+
if ALINE_HOOK_MARKER in raw:
|
|
350
|
+
installed = True
|
|
351
|
+
elif "notify" in raw and "notify_hook.py" in raw:
|
|
352
|
+
installed = True
|
|
353
|
+
else:
|
|
354
|
+
installed = False
|
|
355
|
+
except Exception:
|
|
356
|
+
installed = False
|
|
357
|
+
|
|
358
|
+
self._codex_notify_hook_cache[key] = (now, installed)
|
|
359
|
+
return installed
|
|
360
|
+
|
|
361
|
+
def _codex_fallback_discover_sessions(self) -> list[Path]:
|
|
362
|
+
"""Discover recent Codex sessions (bounded scan), best-effort."""
|
|
363
|
+
try:
|
|
364
|
+
from .adapters import get_adapter_registry
|
|
365
|
+
|
|
366
|
+
adapter = get_adapter_registry().get_adapter("codex")
|
|
367
|
+
if not adapter:
|
|
368
|
+
return []
|
|
369
|
+
return [Path(p) for p in (adapter.discover_sessions() or [])]
|
|
370
|
+
except Exception:
|
|
371
|
+
return []
|
|
372
|
+
|
|
373
|
+
async def _codex_fallback_poll_sessions(self) -> None:
|
|
374
|
+
"""Fallback: enqueue Codex session_process jobs by scanning recent session files.
|
|
375
|
+
|
|
376
|
+
Only runs when:
|
|
377
|
+
- Codex auto-detection is enabled, and
|
|
378
|
+
- watcher polling is disabled (signal-driven), and
|
|
379
|
+
- Codex notify hook isn't available/reliable.
|
|
380
|
+
"""
|
|
381
|
+
if not getattr(self.config, "auto_detect_codex", False):
|
|
382
|
+
return
|
|
383
|
+
if not self._codex_fallback_poll_enabled:
|
|
384
|
+
return
|
|
385
|
+
if self._polling_enabled:
|
|
386
|
+
# Main polling already covers Codex via adapter discovery.
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
now = time.time()
|
|
390
|
+
interval = float(self._codex_fallback_poll_interval_seconds)
|
|
391
|
+
if (now - float(self._codex_fallback_poll_last_scan or 0.0)) < interval:
|
|
392
|
+
return
|
|
393
|
+
self._codex_fallback_poll_last_scan = now
|
|
394
|
+
|
|
395
|
+
session_files = self._codex_fallback_discover_sessions()
|
|
396
|
+
if not session_files:
|
|
397
|
+
return
|
|
398
|
+
|
|
399
|
+
current_sizes: Dict[str, int] = {}
|
|
400
|
+
current_mtimes: Dict[str, float] = {}
|
|
401
|
+
for p in session_files:
|
|
402
|
+
try:
|
|
403
|
+
st = p.stat()
|
|
404
|
+
except Exception:
|
|
405
|
+
continue
|
|
406
|
+
k = str(p)
|
|
407
|
+
try:
|
|
408
|
+
current_sizes[k] = int(st.st_size)
|
|
409
|
+
current_mtimes[k] = float(st.st_mtime)
|
|
410
|
+
except Exception:
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
notify_supported = self._codex_notify_supported is True
|
|
414
|
+
changed: list[Path] = []
|
|
415
|
+
for path_key, size in current_sizes.items():
|
|
416
|
+
old_size = self._codex_fallback_last_sizes.get(path_key)
|
|
417
|
+
old_mtime = self._codex_fallback_last_mtimes.get(path_key)
|
|
418
|
+
mtime = current_mtimes.get(path_key)
|
|
419
|
+
if old_size is None or old_mtime is None:
|
|
420
|
+
changed.append(Path(path_key))
|
|
421
|
+
try:
|
|
422
|
+
self._maybe_link_codex_terminal(Path(path_key))
|
|
423
|
+
except Exception:
|
|
424
|
+
pass
|
|
425
|
+
continue
|
|
426
|
+
# If notify hook works *and* is installed for this CODEX_HOME, we only need
|
|
427
|
+
# "new session discovery" here. Let notify drive subsequent updates to avoid
|
|
428
|
+
# duplicate enqueues. If the hook isn't installed (common when Codex sessions are
|
|
429
|
+
# started outside dashboard-managed CODEX_HOME prep), keep polling updates so
|
|
430
|
+
# turns still get imported and titles can be generated.
|
|
431
|
+
if notify_supported:
|
|
432
|
+
try:
|
|
433
|
+
hook_installed = self._codex_notify_hook_installed_for_session_file(Path(path_key))
|
|
434
|
+
except Exception:
|
|
435
|
+
hook_installed = None
|
|
436
|
+
if hook_installed is True:
|
|
437
|
+
continue
|
|
438
|
+
if size != old_size or (mtime is not None and mtime != old_mtime):
|
|
439
|
+
changed.append(Path(path_key))
|
|
440
|
+
|
|
441
|
+
self._codex_fallback_last_sizes = current_sizes
|
|
442
|
+
self._codex_fallback_last_mtimes = current_mtimes
|
|
443
|
+
|
|
444
|
+
if not changed:
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
changed.sort(key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True)
|
|
449
|
+
except Exception:
|
|
450
|
+
pass
|
|
451
|
+
|
|
452
|
+
from .db import get_database
|
|
453
|
+
|
|
454
|
+
db = get_database()
|
|
455
|
+
# Throttle burst enqueues to keep UI responsive if many Codex sessions exist.
|
|
456
|
+
for session_file in changed[:50]:
|
|
457
|
+
if not session_file.exists():
|
|
458
|
+
continue
|
|
459
|
+
try:
|
|
460
|
+
db.enqueue_session_process_job( # type: ignore[attr-defined]
|
|
461
|
+
session_file_path=session_file,
|
|
462
|
+
session_id=session_file.stem,
|
|
463
|
+
workspace_path=None,
|
|
464
|
+
session_type="codex",
|
|
465
|
+
source_event="codex_poll",
|
|
466
|
+
priority=20,
|
|
467
|
+
)
|
|
468
|
+
except Exception:
|
|
469
|
+
continue
|
|
470
|
+
|
|
267
471
|
def _get_cached_session_list(self, force_rescan: bool = False) -> list[Path]:
|
|
268
472
|
"""Return cached list of active session files, re-scanning only every 30s.
|
|
269
473
|
|
|
@@ -290,13 +494,15 @@ class DialogueWatcher:
|
|
|
290
494
|
|
|
291
495
|
return self._cached_session_list
|
|
292
496
|
|
|
293
|
-
def _get_cycle_session_stats(
|
|
497
|
+
def _get_cycle_session_stats(
|
|
498
|
+
self, *, force_rescan: bool = False
|
|
499
|
+
) -> tuple[list[Path], Dict[str, int], Dict[str, float]]:
|
|
294
500
|
"""Single stat() pass per cycle over the cached session list.
|
|
295
501
|
|
|
296
502
|
Returns:
|
|
297
503
|
(session_files, sizes_dict, mtimes_dict)
|
|
298
504
|
"""
|
|
299
|
-
session_files = self._get_cached_session_list()
|
|
505
|
+
session_files = self._get_cached_session_list(force_rescan=force_rescan)
|
|
300
506
|
sizes: Dict[str, int] = {}
|
|
301
507
|
mtimes: Dict[str, float] = {}
|
|
302
508
|
force_rescan = False
|
|
@@ -336,6 +542,208 @@ class DialogueWatcher:
|
|
|
336
542
|
live_files = [f for f in session_files if str(f) in sizes]
|
|
337
543
|
return live_files, sizes, mtimes
|
|
338
544
|
|
|
545
|
+
def _watcher_session_stats_path(self) -> Path:
|
|
546
|
+
root = Path.home() / ".aline"
|
|
547
|
+
try:
|
|
548
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
549
|
+
except Exception:
|
|
550
|
+
pass
|
|
551
|
+
return root / "watcher_session_stats.json"
|
|
552
|
+
|
|
553
|
+
def _load_persisted_session_stats(self) -> dict[str, dict[str, float]]:
|
|
554
|
+
"""Load last-run session stats (best-effort)."""
|
|
555
|
+
path = self._watcher_session_stats_path()
|
|
556
|
+
if not path.exists():
|
|
557
|
+
return {}
|
|
558
|
+
try:
|
|
559
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
560
|
+
except Exception:
|
|
561
|
+
return {}
|
|
562
|
+
if not isinstance(raw, dict):
|
|
563
|
+
return {}
|
|
564
|
+
out: dict[str, dict[str, float]] = {}
|
|
565
|
+
for k, v in raw.items():
|
|
566
|
+
if not isinstance(k, str) or not isinstance(v, dict):
|
|
567
|
+
continue
|
|
568
|
+
try:
|
|
569
|
+
size = float(v.get("size") or 0.0)
|
|
570
|
+
mtime = float(v.get("mtime") or 0.0)
|
|
571
|
+
except Exception:
|
|
572
|
+
continue
|
|
573
|
+
out[k] = {"size": size, "mtime": mtime}
|
|
574
|
+
return out
|
|
575
|
+
|
|
576
|
+
def _save_persisted_session_stats(
|
|
577
|
+
self, sizes: Dict[str, int], mtimes: Dict[str, float]
|
|
578
|
+
) -> None:
|
|
579
|
+
"""Persist current session stats for next startup scan (best-effort, atomic)."""
|
|
580
|
+
path = self._watcher_session_stats_path()
|
|
581
|
+
tmp = path.with_suffix(".json.tmp")
|
|
582
|
+
payload: dict[str, dict[str, float]] = {}
|
|
583
|
+
for p, size in (sizes or {}).items():
|
|
584
|
+
try:
|
|
585
|
+
payload[str(p)] = {
|
|
586
|
+
"size": float(size or 0),
|
|
587
|
+
"mtime": float(mtimes.get(p, 0.0) or 0.0),
|
|
588
|
+
}
|
|
589
|
+
except Exception:
|
|
590
|
+
continue
|
|
591
|
+
try:
|
|
592
|
+
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
593
|
+
tmp.replace(path)
|
|
594
|
+
except Exception:
|
|
595
|
+
return
|
|
596
|
+
|
|
597
|
+
def _get_stats_for_paths(self, paths: list[Path]) -> tuple[Dict[str, int], Dict[str, float]]:
|
|
598
|
+
"""Stat session files by explicit path (fast path for startup scan)."""
|
|
599
|
+
sizes: Dict[str, int] = {}
|
|
600
|
+
mtimes: Dict[str, float] = {}
|
|
601
|
+
for p in paths:
|
|
602
|
+
try:
|
|
603
|
+
st = p.stat()
|
|
604
|
+
except Exception:
|
|
605
|
+
continue
|
|
606
|
+
key = str(p)
|
|
607
|
+
try:
|
|
608
|
+
sizes[key] = int(st.st_size)
|
|
609
|
+
mtimes[key] = float(st.st_mtime)
|
|
610
|
+
except Exception:
|
|
611
|
+
continue
|
|
612
|
+
return sizes, mtimes
|
|
613
|
+
|
|
614
|
+
def _startup_scan_collect_candidates(
|
|
615
|
+
self,
|
|
616
|
+
) -> tuple[list[Path], Dict[str, int], Dict[str, float], StartupScanReport]:
|
|
617
|
+
"""
|
|
618
|
+
Collect backlog session files to enqueue on startup.
|
|
619
|
+
|
|
620
|
+
Two phases:
|
|
621
|
+
1) Fast path: re-stat previously persisted session paths (no directory scan).
|
|
622
|
+
2) Full scan: scan active session directories to find new/unknown sessions.
|
|
623
|
+
"""
|
|
624
|
+
prev = self._load_persisted_session_stats()
|
|
625
|
+
|
|
626
|
+
prev_paths: list[Path] = []
|
|
627
|
+
for k in prev.keys():
|
|
628
|
+
if not isinstance(k, str):
|
|
629
|
+
continue
|
|
630
|
+
if not k or k in (".", ".."):
|
|
631
|
+
continue
|
|
632
|
+
try:
|
|
633
|
+
prev_paths.append(Path(k))
|
|
634
|
+
except Exception:
|
|
635
|
+
continue
|
|
636
|
+
|
|
637
|
+
# Phase 1: fast path stats for known session file paths.
|
|
638
|
+
known_sizes, known_mtimes = self._get_stats_for_paths(prev_paths)
|
|
639
|
+
prev_missing = max(0, len(prev_paths) - len(known_sizes))
|
|
640
|
+
|
|
641
|
+
candidates: list[Path] = []
|
|
642
|
+
candidate_keys: set[str] = set()
|
|
643
|
+
prev_changed = 0
|
|
644
|
+
|
|
645
|
+
for p in prev_paths:
|
|
646
|
+
key = str(p)
|
|
647
|
+
if key not in known_sizes:
|
|
648
|
+
continue
|
|
649
|
+
size = int(known_sizes.get(key, 0) or 0)
|
|
650
|
+
mtime = float(known_mtimes.get(key, 0.0) or 0.0)
|
|
651
|
+
prev_stats = prev.get(key) or {}
|
|
652
|
+
if float(prev_stats.get("size") or 0.0) == float(size) and float(
|
|
653
|
+
prev_stats.get("mtime") or 0.0
|
|
654
|
+
) == float(mtime):
|
|
655
|
+
continue
|
|
656
|
+
prev_changed += 1
|
|
657
|
+
if key not in candidate_keys:
|
|
658
|
+
candidates.append(p)
|
|
659
|
+
candidate_keys.add(key)
|
|
660
|
+
|
|
661
|
+
# Phase 2: full scan of watch paths to find unknown/new sessions.
|
|
662
|
+
session_files, scan_sizes, scan_mtimes = self._get_cycle_session_stats(force_rescan=True)
|
|
663
|
+
scan_new = 0
|
|
664
|
+
scan_changed = 0
|
|
665
|
+
|
|
666
|
+
for session_file in session_files:
|
|
667
|
+
key = str(session_file)
|
|
668
|
+
size = scan_sizes.get(key)
|
|
669
|
+
mtime = scan_mtimes.get(key)
|
|
670
|
+
if size is None or mtime is None:
|
|
671
|
+
continue
|
|
672
|
+
prev_stats = prev.get(key)
|
|
673
|
+
if prev_stats is None:
|
|
674
|
+
scan_new += 1
|
|
675
|
+
if key not in candidate_keys:
|
|
676
|
+
candidates.append(session_file)
|
|
677
|
+
candidate_keys.add(key)
|
|
678
|
+
continue
|
|
679
|
+
if float(prev_stats.get("size") or 0.0) == float(size) and float(
|
|
680
|
+
prev_stats.get("mtime") or 0.0
|
|
681
|
+
) == float(mtime):
|
|
682
|
+
continue
|
|
683
|
+
scan_changed += 1
|
|
684
|
+
if key not in candidate_keys:
|
|
685
|
+
candidates.append(session_file)
|
|
686
|
+
candidate_keys.add(key)
|
|
687
|
+
|
|
688
|
+
# Merge baselines for next cycle; include known paths even if no longer in scan.
|
|
689
|
+
merged_sizes: Dict[str, int] = dict(known_sizes)
|
|
690
|
+
merged_sizes.update(scan_sizes)
|
|
691
|
+
merged_mtimes: Dict[str, float] = dict(known_mtimes)
|
|
692
|
+
merged_mtimes.update(scan_mtimes)
|
|
693
|
+
|
|
694
|
+
report = StartupScanReport(
|
|
695
|
+
prev_paths=len(prev_paths),
|
|
696
|
+
prev_missing=int(prev_missing),
|
|
697
|
+
prev_changed=int(prev_changed),
|
|
698
|
+
scan_paths=len(session_files),
|
|
699
|
+
scan_new=int(scan_new),
|
|
700
|
+
scan_changed=int(scan_changed),
|
|
701
|
+
candidates=len(candidates),
|
|
702
|
+
)
|
|
703
|
+
return candidates, merged_sizes, merged_mtimes, report
|
|
704
|
+
|
|
705
|
+
async def _startup_scan_enqueue_changed_sessions(self) -> None:
|
|
706
|
+
"""Scan sessions once on startup and enqueue backlog work (low priority)."""
|
|
707
|
+
try:
|
|
708
|
+
candidates, sizes, mtimes, report = self._startup_scan_collect_candidates()
|
|
709
|
+
|
|
710
|
+
# Update in-memory baselines for potential later use (even if polling is disabled).
|
|
711
|
+
self.last_session_sizes = sizes
|
|
712
|
+
self.last_session_mtimes = mtimes
|
|
713
|
+
|
|
714
|
+
from .db import get_database
|
|
715
|
+
|
|
716
|
+
db = get_database()
|
|
717
|
+
|
|
718
|
+
enqueued = 0
|
|
719
|
+
for session_file in candidates:
|
|
720
|
+
try:
|
|
721
|
+
db.enqueue_session_process_job( # type: ignore[attr-defined]
|
|
722
|
+
session_file_path=session_file,
|
|
723
|
+
session_id=session_file.stem,
|
|
724
|
+
workspace_path=None,
|
|
725
|
+
session_type=self._detect_session_type(session_file),
|
|
726
|
+
source_event="startup_scan",
|
|
727
|
+
priority=self._startup_scan_priority,
|
|
728
|
+
)
|
|
729
|
+
enqueued += 1
|
|
730
|
+
except Exception:
|
|
731
|
+
continue
|
|
732
|
+
|
|
733
|
+
if enqueued:
|
|
734
|
+
logger.info(
|
|
735
|
+
"Startup scan enqueued %s session_process job(s) (prev=%s missing=%s prev_changed=%s scan=%s new=%s scan_changed=%s)",
|
|
736
|
+
enqueued,
|
|
737
|
+
report.prev_paths,
|
|
738
|
+
report.prev_missing,
|
|
739
|
+
report.prev_changed,
|
|
740
|
+
report.scan_paths,
|
|
741
|
+
report.scan_new,
|
|
742
|
+
report.scan_changed,
|
|
743
|
+
)
|
|
744
|
+
except Exception as e:
|
|
745
|
+
logger.warning(f"Startup scan failed: {e}")
|
|
746
|
+
|
|
339
747
|
def _get_cached_turn_counts(
|
|
340
748
|
self, session_file: Path, current_mtime: float, current_size: int
|
|
341
749
|
) -> tuple[int, int]:
|
|
@@ -376,57 +784,17 @@ class DialogueWatcher:
|
|
|
376
784
|
except Exception:
|
|
377
785
|
return
|
|
378
786
|
|
|
379
|
-
def _agent_info_id_for_codex_session(
|
|
380
|
-
self, session_file: Path, *, db=None
|
|
381
|
-
) -> Optional[str]:
|
|
382
|
-
"""Best-effort: resolve agent_info_id from a codex session file."""
|
|
383
|
-
try:
|
|
384
|
-
from .codex_home import codex_home_owner_from_session_file
|
|
385
|
-
except Exception:
|
|
386
|
-
return None
|
|
387
|
-
|
|
388
|
-
try:
|
|
389
|
-
owner = codex_home_owner_from_session_file(session_file)
|
|
390
|
-
except Exception:
|
|
391
|
-
owner = None
|
|
392
|
-
if not owner or owner[0] != "terminal":
|
|
393
|
-
return None
|
|
394
|
-
terminal_id = owner[1]
|
|
395
|
-
if not terminal_id:
|
|
396
|
-
return None
|
|
397
|
-
|
|
398
|
-
try:
|
|
399
|
-
if db is None:
|
|
400
|
-
from .db import get_database
|
|
401
|
-
|
|
402
|
-
db = get_database(read_only=True)
|
|
403
|
-
agent = db.get_agent_by_id(terminal_id)
|
|
404
|
-
except Exception:
|
|
405
|
-
return None
|
|
406
|
-
|
|
407
|
-
source = (agent.source or "").strip() if agent else ""
|
|
408
|
-
if source.startswith("agent:"):
|
|
409
|
-
return source[6:]
|
|
410
|
-
return None
|
|
411
|
-
|
|
412
|
-
def _terminal_id_for_codex_session(self, session_file: Path) -> Optional[str]:
|
|
413
|
-
"""Best-effort: resolve terminal_id from codex session file path."""
|
|
414
787
|
try:
|
|
415
|
-
from
|
|
416
|
-
except Exception:
|
|
417
|
-
return None
|
|
788
|
+
from datetime import datetime, timezone
|
|
418
789
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
try:
|
|
428
|
-
from .codex_home import codex_home_owner_from_session_file
|
|
429
|
-
from .codex_terminal_linker import read_codex_session_meta, select_agent_for_codex_session
|
|
790
|
+
from .codex_home import (
|
|
791
|
+
agent_id_from_codex_session_file,
|
|
792
|
+
codex_home_owner_from_session_file,
|
|
793
|
+
)
|
|
794
|
+
from .codex_terminal_linker import (
|
|
795
|
+
read_codex_session_meta,
|
|
796
|
+
select_agent_for_codex_session,
|
|
797
|
+
)
|
|
430
798
|
from .db import get_database
|
|
431
799
|
|
|
432
800
|
meta = read_codex_session_meta(session_file)
|
|
@@ -439,11 +807,14 @@ class DialogueWatcher:
|
|
|
439
807
|
owner = codex_home_owner_from_session_file(session_file)
|
|
440
808
|
agent_id = None
|
|
441
809
|
agent_info_id = None
|
|
810
|
+
path_agent_info_id = agent_id_from_codex_session_file(session_file)
|
|
811
|
+
owner_agent_info_id = path_agent_info_id
|
|
442
812
|
if owner:
|
|
443
813
|
if owner[0] == "terminal":
|
|
444
814
|
agent_id = owner[1]
|
|
445
815
|
elif owner[0] == "agent":
|
|
446
816
|
agent_info_id = owner[1]
|
|
817
|
+
owner_agent_info_id = agent_info_id
|
|
447
818
|
scoped_agents = [
|
|
448
819
|
a
|
|
449
820
|
for a in agents
|
|
@@ -460,7 +831,6 @@ class DialogueWatcher:
|
|
|
460
831
|
if not agent_id:
|
|
461
832
|
return
|
|
462
833
|
|
|
463
|
-
owner_agent_info_id = agent_info_id
|
|
464
834
|
# Get existing agent to preserve agent_info_id in source field
|
|
465
835
|
existing_agent = db.get_agent_by_id(agent_id)
|
|
466
836
|
agent_info_id = None
|
|
@@ -506,12 +876,84 @@ class DialogueWatcher:
|
|
|
506
876
|
# Link session to agent_info if available (bidirectional linking)
|
|
507
877
|
if agent_info_id:
|
|
508
878
|
try:
|
|
879
|
+
# Ensure the session row exists so the agent association doesn't get lost
|
|
880
|
+
# when this is called before the session is processed into the DB.
|
|
881
|
+
started_at = meta.started_at
|
|
882
|
+
if started_at is None:
|
|
883
|
+
started_at = datetime.fromtimestamp(session_file.stat().st_mtime)
|
|
884
|
+
elif started_at.tzinfo is not None:
|
|
885
|
+
started_at = started_at.astimezone(timezone.utc).replace(tzinfo=None)
|
|
886
|
+
db.get_or_create_session(
|
|
887
|
+
session_id=session_file.stem,
|
|
888
|
+
session_file_path=session_file,
|
|
889
|
+
session_type="codex",
|
|
890
|
+
started_at=started_at,
|
|
891
|
+
workspace_path=meta.cwd,
|
|
892
|
+
metadata={"source": "codex:watcher"},
|
|
893
|
+
agent_id=agent_info_id,
|
|
894
|
+
)
|
|
509
895
|
db.update_session_agent_id(session_file.stem, agent_info_id)
|
|
510
896
|
except Exception:
|
|
511
897
|
pass
|
|
512
898
|
except Exception:
|
|
513
899
|
return
|
|
514
900
|
|
|
901
|
+
def _agent_info_id_for_codex_session(self, session_file: Path, *, db=None) -> Optional[str]:
|
|
902
|
+
"""Best-effort: resolve agent_info_id from a codex session file."""
|
|
903
|
+
try:
|
|
904
|
+
from .codex_home import (
|
|
905
|
+
agent_id_from_codex_session_file,
|
|
906
|
+
codex_home_owner_from_session_file,
|
|
907
|
+
)
|
|
908
|
+
except Exception:
|
|
909
|
+
return None
|
|
910
|
+
|
|
911
|
+
try:
|
|
912
|
+
agent_id = agent_id_from_codex_session_file(session_file)
|
|
913
|
+
if agent_id:
|
|
914
|
+
return agent_id
|
|
915
|
+
except Exception:
|
|
916
|
+
pass
|
|
917
|
+
|
|
918
|
+
try:
|
|
919
|
+
owner = codex_home_owner_from_session_file(session_file)
|
|
920
|
+
except Exception:
|
|
921
|
+
owner = None
|
|
922
|
+
if not owner or owner[0] != "terminal":
|
|
923
|
+
return None
|
|
924
|
+
terminal_id = owner[1]
|
|
925
|
+
if not terminal_id:
|
|
926
|
+
return None
|
|
927
|
+
|
|
928
|
+
try:
|
|
929
|
+
if db is None:
|
|
930
|
+
from .db import get_database
|
|
931
|
+
|
|
932
|
+
db = get_database(read_only=True)
|
|
933
|
+
agent = db.get_agent_by_id(terminal_id)
|
|
934
|
+
except Exception:
|
|
935
|
+
return None
|
|
936
|
+
|
|
937
|
+
source = (agent.source or "").strip() if agent else ""
|
|
938
|
+
if source.startswith("agent:"):
|
|
939
|
+
return source[6:]
|
|
940
|
+
return None
|
|
941
|
+
|
|
942
|
+
def _terminal_id_for_codex_session(self, session_file: Path) -> Optional[str]:
|
|
943
|
+
"""Best-effort: resolve terminal_id from codex session file path."""
|
|
944
|
+
try:
|
|
945
|
+
from .codex_home import codex_home_owner_from_session_file
|
|
946
|
+
except Exception:
|
|
947
|
+
return None
|
|
948
|
+
|
|
949
|
+
try:
|
|
950
|
+
owner = codex_home_owner_from_session_file(session_file)
|
|
951
|
+
except Exception:
|
|
952
|
+
owner = None
|
|
953
|
+
if not owner or owner[0] != "terminal":
|
|
954
|
+
return None
|
|
955
|
+
return owner[1]
|
|
956
|
+
|
|
515
957
|
async def start(self):
|
|
516
958
|
"""Start watching session files."""
|
|
517
959
|
if not self.config.mcp_auto_commit:
|
|
@@ -536,17 +978,75 @@ class DialogueWatcher:
|
|
|
536
978
|
file=sys.stderr,
|
|
537
979
|
)
|
|
538
980
|
|
|
981
|
+
# Codex: require Rust CLI notify hook for reliable integration.
|
|
982
|
+
if getattr(self.config, "auto_detect_codex", False):
|
|
983
|
+
try:
|
|
984
|
+
from .codex_hooks.notify_hook_installer import codex_cli_supports_notify_hook
|
|
985
|
+
|
|
986
|
+
supported = codex_cli_supports_notify_hook()
|
|
987
|
+
except Exception:
|
|
988
|
+
supported = None
|
|
989
|
+
|
|
990
|
+
self._codex_notify_supported = supported
|
|
991
|
+
|
|
992
|
+
if supported is False:
|
|
993
|
+
# Notify hook unsupported: warn. We'll still run a Codex-only fallback poll in
|
|
994
|
+
# signal-driven mode, so sessions are discovered even without notify.
|
|
995
|
+
if not getattr(self, "_codex_legacy_warning_logged", False):
|
|
996
|
+
self._codex_legacy_warning_logged = True
|
|
997
|
+
msg = (
|
|
998
|
+
"[Watcher] Codex detected, but your Codex CLI does not support the Rust "
|
|
999
|
+
"notify hook. Please update Codex CLI to a recent Rust version. "
|
|
1000
|
+
"Falling back to lightweight Codex polling."
|
|
1001
|
+
)
|
|
1002
|
+
print(msg, file=sys.stderr)
|
|
1003
|
+
logger.warning(msg)
|
|
1004
|
+
elif supported is None:
|
|
1005
|
+
# If we can't detect support (e.g. codex not on PATH), don't spam logs. The
|
|
1006
|
+
# fallback poll will still provide best-effort discovery.
|
|
1007
|
+
self._codex_notify_supported = None
|
|
1008
|
+
|
|
539
1009
|
# Auto-install Claude Code Stop hook for reliable turn completion detection
|
|
1010
|
+
stop_hook_ready = False
|
|
540
1011
|
try:
|
|
541
1012
|
from .claude_hooks.stop_hook_installer import ensure_stop_hook_installed
|
|
542
1013
|
|
|
543
1014
|
if ensure_stop_hook_installed(quiet=True):
|
|
544
1015
|
logger.info("Claude Code Stop hook is ready")
|
|
1016
|
+
stop_hook_ready = True
|
|
545
1017
|
else:
|
|
546
1018
|
logger.warning("Failed to install Stop hook, falling back to polling-only mode")
|
|
547
1019
|
except Exception as e:
|
|
548
1020
|
logger.debug(f"Stop hook installation skipped: {e}")
|
|
549
1021
|
|
|
1022
|
+
disable_polling = os.environ.get("ALINE_WATCHER_DISABLE_POLLING", "") == "1"
|
|
1023
|
+
force_polling = os.environ.get("ALINE_WATCHER_ENABLE_POLLING", "") == "1"
|
|
1024
|
+
if force_polling:
|
|
1025
|
+
self._polling_enabled = True
|
|
1026
|
+
elif disable_polling:
|
|
1027
|
+
self._polling_enabled = False
|
|
1028
|
+
else:
|
|
1029
|
+
# Default: no polling (event-driven hooks preferred). Use --enable env var to opt in.
|
|
1030
|
+
self._polling_enabled = False
|
|
1031
|
+
|
|
1032
|
+
if self._polling_enabled:
|
|
1033
|
+
logger.info("Watcher polling enabled (legacy fallback mode)")
|
|
1034
|
+
else:
|
|
1035
|
+
logger.info("Watcher polling disabled (stop-hook/signal-driven mode)")
|
|
1036
|
+
|
|
1037
|
+
# In signal-driven mode, also do a lightweight Codex-only fallback poll so new Codex
|
|
1038
|
+
# sessions show up even before the first notify event.
|
|
1039
|
+
try:
|
|
1040
|
+
disable_codex_fallback = os.environ.get("ALINE_CODEX_DISABLE_FALLBACK_POLL", "") == "1"
|
|
1041
|
+
except Exception:
|
|
1042
|
+
disable_codex_fallback = False
|
|
1043
|
+
if (
|
|
1044
|
+
not disable_codex_fallback
|
|
1045
|
+
and not self._polling_enabled
|
|
1046
|
+
and getattr(self.config, "auto_detect_codex", False)
|
|
1047
|
+
):
|
|
1048
|
+
self._codex_fallback_poll_enabled = True
|
|
1049
|
+
|
|
550
1050
|
if self.config.enable_temp_turn_titles:
|
|
551
1051
|
# Auto-install Claude Code UserPromptSubmit hook for temp title generation
|
|
552
1052
|
try:
|
|
@@ -561,10 +1061,6 @@ class DialogueWatcher:
|
|
|
561
1061
|
except Exception as e:
|
|
562
1062
|
logger.debug(f"UserPromptSubmit hook installation skipped: {e}")
|
|
563
1063
|
|
|
564
|
-
# Initialize baseline sizes and stop_reason counts
|
|
565
|
-
self.last_session_sizes, self.last_session_mtimes = self._get_session_stats()
|
|
566
|
-
self.last_stop_reason_counts = self._get_stop_reason_counts()
|
|
567
|
-
|
|
568
1064
|
# Note: Idle timeout checking is now integrated into main loop instead of separate task
|
|
569
1065
|
|
|
570
1066
|
# Ensure global config/database exists (no per-project init)
|
|
@@ -572,8 +1068,8 @@ class DialogueWatcher:
|
|
|
572
1068
|
print("[Watcher] Ensuring global Aline initialization", file=sys.stderr)
|
|
573
1069
|
await self.auto_init_projects()
|
|
574
1070
|
|
|
575
|
-
#
|
|
576
|
-
await self.
|
|
1071
|
+
# Startup scan: enqueue backlog sessions changed since last run (no debounce, low priority).
|
|
1072
|
+
await self._startup_scan_enqueue_changed_sessions()
|
|
577
1073
|
|
|
578
1074
|
# Poll for file changes more frequently
|
|
579
1075
|
while self.running:
|
|
@@ -581,19 +1077,23 @@ class DialogueWatcher:
|
|
|
581
1077
|
# Priority 1: Check Stop hook signals (immediate trigger, no debounce)
|
|
582
1078
|
await self._check_stop_signals()
|
|
583
1079
|
|
|
1080
|
+
# Priority 1.5: Codex notify fallback signals (rare).
|
|
1081
|
+
await self._check_codex_notify_signals()
|
|
1082
|
+
|
|
1083
|
+
# Priority 1.6: Codex fallback polling when notify is unavailable.
|
|
1084
|
+
await self._codex_fallback_poll_sessions()
|
|
1085
|
+
|
|
584
1086
|
# Priority 2: Check UserPromptSubmit signals (temp title generation)
|
|
585
1087
|
if self.config.enable_temp_turn_titles:
|
|
586
1088
|
await self._check_user_prompt_submit_signals()
|
|
587
1089
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
# Priority 2: Fallback polling mechanism (with debounce)
|
|
593
|
-
await self.check_for_changes(cycle_sizes, cycle_mtimes)
|
|
1090
|
+
if self._polling_enabled:
|
|
1091
|
+
# Single stat pass per cycle (Layer 2)
|
|
1092
|
+
cycle_sessions, cycle_sizes, cycle_mtimes = self._get_cycle_session_stats()
|
|
1093
|
+
self._last_cycle_sizes = cycle_sizes
|
|
594
1094
|
|
|
595
|
-
|
|
596
|
-
|
|
1095
|
+
# Legacy: fallback polling mechanism (debounced)
|
|
1096
|
+
await self.check_for_changes(cycle_sizes, cycle_mtimes)
|
|
597
1097
|
|
|
598
1098
|
await asyncio.sleep(0.5) # Check every 0.5 seconds for responsiveness
|
|
599
1099
|
except Exception as e:
|
|
@@ -606,6 +1106,10 @@ class DialogueWatcher:
|
|
|
606
1106
|
self.running = False
|
|
607
1107
|
if self.pending_commit_task:
|
|
608
1108
|
self.pending_commit_task.cancel()
|
|
1109
|
+
try:
|
|
1110
|
+
self._save_persisted_session_stats(self.last_session_sizes, self.last_session_mtimes)
|
|
1111
|
+
except Exception:
|
|
1112
|
+
pass
|
|
609
1113
|
logger.info("Watcher stopped")
|
|
610
1114
|
print("[Watcher] Stopped", file=sys.stderr)
|
|
611
1115
|
|
|
@@ -628,6 +1132,12 @@ class DialogueWatcher:
|
|
|
628
1132
|
|
|
629
1133
|
for signal_file in self.signal_dir.glob("*.signal"):
|
|
630
1134
|
try:
|
|
1135
|
+
signal_key = str(signal_file)
|
|
1136
|
+
now = time.time()
|
|
1137
|
+
retry_after = float(self._stop_signal_retry_after.get(signal_key, 0.0) or 0.0)
|
|
1138
|
+
if retry_after and now < retry_after:
|
|
1139
|
+
continue
|
|
1140
|
+
|
|
631
1141
|
# Read signal data
|
|
632
1142
|
signal_data = json.loads(signal_file.read_text())
|
|
633
1143
|
session_id = signal_data.get("session_id", "")
|
|
@@ -641,67 +1151,69 @@ class DialogueWatcher:
|
|
|
641
1151
|
|
|
642
1152
|
# Find the session file
|
|
643
1153
|
session_file = None
|
|
644
|
-
if transcript_path
|
|
645
|
-
|
|
1154
|
+
if transcript_path:
|
|
1155
|
+
candidate = Path(transcript_path)
|
|
1156
|
+
if candidate.exists():
|
|
1157
|
+
session_file = candidate
|
|
646
1158
|
elif session_id:
|
|
647
|
-
|
|
1159
|
+
# Lightweight fallback: avoid global scanning; only consider sessions we already
|
|
1160
|
+
# know about from startup scan / previous signals.
|
|
1161
|
+
for known_path in list(self.last_session_sizes.keys()):
|
|
1162
|
+
try:
|
|
1163
|
+
p = Path(known_path)
|
|
1164
|
+
except Exception:
|
|
1165
|
+
continue
|
|
1166
|
+
if p.stem != session_id:
|
|
1167
|
+
continue
|
|
1168
|
+
if p.exists():
|
|
1169
|
+
session_file = p
|
|
1170
|
+
break
|
|
648
1171
|
|
|
649
1172
|
if session_file and session_file.exists():
|
|
650
|
-
#
|
|
651
|
-
|
|
652
|
-
project_path = Path(project_dir)
|
|
653
|
-
else:
|
|
654
|
-
project_path = self._extract_project_path(session_file)
|
|
655
|
-
|
|
656
|
-
if project_path:
|
|
657
|
-
# Calculate the actual turn number that just completed.
|
|
658
|
-
# For Claude, count_complete_turns intentionally excludes the last turn,
|
|
659
|
-
# so we use total_turns when available.
|
|
660
|
-
target_turn = self._get_total_turn_count(session_file)
|
|
1173
|
+
# Enqueue a per-session job; worker will determine which turns to process.
|
|
1174
|
+
from .db import get_database
|
|
661
1175
|
|
|
662
|
-
|
|
663
|
-
|
|
1176
|
+
db = get_database()
|
|
1177
|
+
try:
|
|
1178
|
+
db.enqueue_session_process_job( # type: ignore[attr-defined]
|
|
1179
|
+
session_file_path=session_file,
|
|
1180
|
+
session_id=session_id or session_file.stem,
|
|
1181
|
+
workspace_path=(project_dir or None),
|
|
1182
|
+
session_type=self._detect_session_type(session_file),
|
|
1183
|
+
source_event="stop",
|
|
1184
|
+
no_track=no_track,
|
|
1185
|
+
agent_id=agent_id if agent_id else None,
|
|
1186
|
+
)
|
|
664
1187
|
|
|
665
|
-
db = get_database()
|
|
666
|
-
try:
|
|
667
|
-
db.enqueue_turn_summary_job( # type: ignore[attr-defined]
|
|
668
|
-
session_file_path=session_file,
|
|
669
|
-
workspace_path=project_path,
|
|
670
|
-
turn_number=target_turn,
|
|
671
|
-
session_type=self._detect_session_type(session_file),
|
|
672
|
-
no_track=no_track,
|
|
673
|
-
agent_id=agent_id if agent_id else None,
|
|
674
|
-
)
|
|
675
|
-
except Exception as e:
|
|
676
|
-
logger.warning(
|
|
677
|
-
f"Failed to enqueue stop-hook job for {session_id}: {e}"
|
|
678
|
-
)
|
|
679
|
-
|
|
680
|
-
# Directly link session to agent (in case the turn job
|
|
681
|
-
# was already processed by polling without agent_id
|
|
682
|
-
# and the new enqueue was deduped).
|
|
683
1188
|
if agent_id and session_id:
|
|
684
1189
|
try:
|
|
685
1190
|
db.update_session_agent_id(session_id, agent_id)
|
|
686
1191
|
except Exception:
|
|
687
1192
|
pass
|
|
688
1193
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
1194
|
+
self._stop_signal_retry_after.pop(signal_key, None)
|
|
1195
|
+
self._stop_signal_failures.pop(signal_key, None)
|
|
1196
|
+
try:
|
|
1197
|
+
st = session_file.stat()
|
|
1198
|
+
self.last_session_sizes[str(session_file)] = int(st.st_size)
|
|
1199
|
+
self.last_session_mtimes[str(session_file)] = float(st.st_mtime)
|
|
1200
|
+
except Exception:
|
|
1201
|
+
pass
|
|
1202
|
+
signal_file.unlink(missing_ok=True)
|
|
1203
|
+
except Exception as e:
|
|
1204
|
+
failures = int(self._stop_signal_failures.get(signal_key, 0) or 0) + 1
|
|
1205
|
+
self._stop_signal_failures[signal_key] = failures
|
|
1206
|
+
delay = float(min(30.0, 1.0 * (2 ** min(failures, 5))))
|
|
1207
|
+
self._stop_signal_retry_after[signal_key] = now + delay
|
|
697
1208
|
logger.warning(
|
|
698
|
-
f"
|
|
1209
|
+
f"Failed to enqueue stop-hook session_process for {session_id} (retry in {delay:.0f}s): {e}"
|
|
699
1210
|
)
|
|
700
1211
|
else:
|
|
701
1212
|
logger.warning(f"Session file not found for {session_id}")
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
1213
|
+
# Keep signal for a short time; file discovery can lag behind hook.
|
|
1214
|
+
failures = int(self._stop_signal_failures.get(signal_key, 0) or 0) + 1
|
|
1215
|
+
self._stop_signal_failures[signal_key] = failures
|
|
1216
|
+
self._stop_signal_retry_after[signal_key] = now + 5.0
|
|
705
1217
|
|
|
706
1218
|
except json.JSONDecodeError as e:
|
|
707
1219
|
logger.warning(f"Invalid signal file {signal_file.name}: {e}")
|
|
@@ -714,6 +1226,79 @@ class DialogueWatcher:
|
|
|
714
1226
|
except Exception as e:
|
|
715
1227
|
logger.error(f"Error checking stop signals: {e}", exc_info=True)
|
|
716
1228
|
|
|
1229
|
+
async def _check_codex_notify_signals(self) -> None:
|
|
1230
|
+
"""Process Codex notify-hook fallback signals (rare path)."""
|
|
1231
|
+
try:
|
|
1232
|
+
from .codex_hooks import codex_notify_signal_dir
|
|
1233
|
+
|
|
1234
|
+
signal_dir = codex_notify_signal_dir()
|
|
1235
|
+
if not signal_dir.exists():
|
|
1236
|
+
return
|
|
1237
|
+
|
|
1238
|
+
now = time.time()
|
|
1239
|
+
for signal_file in signal_dir.glob("*.signal"):
|
|
1240
|
+
try:
|
|
1241
|
+
data = json.loads(signal_file.read_text(encoding="utf-8"))
|
|
1242
|
+
except Exception:
|
|
1243
|
+
signal_file.unlink(missing_ok=True)
|
|
1244
|
+
continue
|
|
1245
|
+
|
|
1246
|
+
ts = float(data.get("timestamp") or 0.0)
|
|
1247
|
+
if ts and now - ts < 0.2:
|
|
1248
|
+
continue
|
|
1249
|
+
|
|
1250
|
+
transcript_path = str(data.get("transcript_path") or "") or str(
|
|
1251
|
+
data.get("session_file_path") or ""
|
|
1252
|
+
)
|
|
1253
|
+
if not transcript_path:
|
|
1254
|
+
signal_file.unlink(missing_ok=True)
|
|
1255
|
+
continue
|
|
1256
|
+
|
|
1257
|
+
session_file = Path(transcript_path)
|
|
1258
|
+
if not session_file.exists():
|
|
1259
|
+
signal_file.unlink(missing_ok=True)
|
|
1260
|
+
continue
|
|
1261
|
+
|
|
1262
|
+
session_id = str(data.get("session_id") or session_file.stem).strip()
|
|
1263
|
+
project_dir = str(data.get("project_dir") or data.get("cwd") or "")
|
|
1264
|
+
no_track = bool(data.get("no_track") or False)
|
|
1265
|
+
agent_id = str(data.get("agent_id") or "").strip()
|
|
1266
|
+
terminal_id = str(data.get("terminal_id") or "").strip()
|
|
1267
|
+
|
|
1268
|
+
from .db import get_database
|
|
1269
|
+
|
|
1270
|
+
db = get_database()
|
|
1271
|
+
try:
|
|
1272
|
+
db.enqueue_session_process_job( # type: ignore[attr-defined]
|
|
1273
|
+
session_file_path=session_file,
|
|
1274
|
+
session_id=session_id,
|
|
1275
|
+
workspace_path=(project_dir or None),
|
|
1276
|
+
session_type="codex",
|
|
1277
|
+
source_event="notify",
|
|
1278
|
+
no_track=no_track,
|
|
1279
|
+
agent_id=agent_id if agent_id else None,
|
|
1280
|
+
terminal_id=terminal_id if terminal_id else None, # type: ignore[arg-type]
|
|
1281
|
+
)
|
|
1282
|
+
except TypeError:
|
|
1283
|
+
try:
|
|
1284
|
+
db.enqueue_session_process_job( # type: ignore[attr-defined]
|
|
1285
|
+
session_file_path=session_file,
|
|
1286
|
+
session_id=session_id,
|
|
1287
|
+
workspace_path=(project_dir or None),
|
|
1288
|
+
session_type="codex",
|
|
1289
|
+
source_event="notify",
|
|
1290
|
+
no_track=no_track,
|
|
1291
|
+
agent_id=agent_id if agent_id else None,
|
|
1292
|
+
)
|
|
1293
|
+
except Exception:
|
|
1294
|
+
pass
|
|
1295
|
+
except Exception:
|
|
1296
|
+
pass
|
|
1297
|
+
|
|
1298
|
+
signal_file.unlink(missing_ok=True)
|
|
1299
|
+
except Exception:
|
|
1300
|
+
return
|
|
1301
|
+
|
|
717
1302
|
async def _check_user_prompt_submit_signals(self):
|
|
718
1303
|
"""Process UserPromptSubmit hook signals for temporary turn titles."""
|
|
719
1304
|
try:
|
|
@@ -818,7 +1403,7 @@ class DialogueWatcher:
|
|
|
818
1403
|
|
|
819
1404
|
Note: for Claude Code, this only covers non-last turns, because
|
|
820
1405
|
ClaudeTrigger.count_complete_turns() intentionally excludes the last turn
|
|
821
|
-
to avoid false positives. The last turn is handled by Stop hook
|
|
1406
|
+
to avoid false positives. The last turn is handled by the Stop hook path.
|
|
822
1407
|
"""
|
|
823
1408
|
session_path = str(session_file)
|
|
824
1409
|
session_type = self._detect_session_type(session_file)
|
|
@@ -1039,160 +1624,6 @@ class DialogueWatcher:
|
|
|
1039
1624
|
logger.warning(f"Failed to compute hash for {session_file.name}: {e}")
|
|
1040
1625
|
return None
|
|
1041
1626
|
|
|
1042
|
-
async def _check_idle_sessions_for_final_commit(self, session_files: Optional[list[Path]] = None, current_mtimes: Optional[Dict[str, float]] = None):
|
|
1043
|
-
"""Check for idle sessions and trigger final commits if needed."""
|
|
1044
|
-
try:
|
|
1045
|
-
current_time = time.time()
|
|
1046
|
-
if session_files is None:
|
|
1047
|
-
session_files = self._get_cached_session_list()
|
|
1048
|
-
if current_mtimes is None:
|
|
1049
|
-
_, _, current_mtimes = self._get_cycle_session_stats()
|
|
1050
|
-
|
|
1051
|
-
for session_file in session_files:
|
|
1052
|
-
session_path = str(session_file)
|
|
1053
|
-
mtime = current_mtimes.get(session_path)
|
|
1054
|
-
if mtime is None:
|
|
1055
|
-
continue
|
|
1056
|
-
|
|
1057
|
-
try:
|
|
1058
|
-
# Initialize tracking if first time seeing this session
|
|
1059
|
-
if session_path not in self.last_session_mtimes:
|
|
1060
|
-
self.last_session_mtimes[session_path] = mtime
|
|
1061
|
-
continue
|
|
1062
|
-
|
|
1063
|
-
last_mtime = self.last_session_mtimes[session_path]
|
|
1064
|
-
|
|
1065
|
-
# If file was modified, update mtime and skip
|
|
1066
|
-
if mtime > last_mtime:
|
|
1067
|
-
self.last_session_mtimes[session_path] = mtime
|
|
1068
|
-
# Reset final commit attempt time when file changes
|
|
1069
|
-
self.last_final_commit_times.pop(session_path, None)
|
|
1070
|
-
continue
|
|
1071
|
-
|
|
1072
|
-
# Check if session has been idle long enough
|
|
1073
|
-
time_since_change = current_time - last_mtime
|
|
1074
|
-
if time_since_change >= self.final_commit_idle_timeout:
|
|
1075
|
-
# Check if we've already tried final commit recently
|
|
1076
|
-
last_attempt = self.last_final_commit_times.get(session_path, 0)
|
|
1077
|
-
if current_time - last_attempt < 60: # Don't try more than once per minute
|
|
1078
|
-
continue
|
|
1079
|
-
|
|
1080
|
-
from .db import get_database
|
|
1081
|
-
|
|
1082
|
-
db = get_database()
|
|
1083
|
-
|
|
1084
|
-
session_id = session_file.stem
|
|
1085
|
-
session_type = self._detect_session_type(session_file)
|
|
1086
|
-
# Use cached turn counts (Layer 3) — avoids re-parsing unchanged files
|
|
1087
|
-
file_size = self._last_cycle_sizes.get(session_path, 0)
|
|
1088
|
-
completed_count, total_turns = self._get_cached_turn_counts(
|
|
1089
|
-
session_file, mtime, file_size
|
|
1090
|
-
)
|
|
1091
|
-
last_count = self.last_stop_reason_counts.get(session_path, 0)
|
|
1092
|
-
|
|
1093
|
-
# For Claude, also consider the last turn (not counted in completed_count).
|
|
1094
|
-
last_turn_to_enqueue: Optional[int] = None
|
|
1095
|
-
if session_type == "claude":
|
|
1096
|
-
if total_turns > completed_count:
|
|
1097
|
-
existing = db.get_turn_by_number(session_id, int(total_turns))
|
|
1098
|
-
if existing is None:
|
|
1099
|
-
last_turn_to_enqueue = int(total_turns)
|
|
1100
|
-
else:
|
|
1101
|
-
existing_status = getattr(existing, "turn_status", None)
|
|
1102
|
-
if existing_status == "processing":
|
|
1103
|
-
try:
|
|
1104
|
-
age_seconds = max(
|
|
1105
|
-
0.0,
|
|
1106
|
-
(
|
|
1107
|
-
datetime.now()
|
|
1108
|
-
- getattr(
|
|
1109
|
-
existing, "created_at", datetime.now()
|
|
1110
|
-
)
|
|
1111
|
-
).total_seconds(),
|
|
1112
|
-
)
|
|
1113
|
-
except Exception:
|
|
1114
|
-
age_seconds = 0.0
|
|
1115
|
-
if age_seconds >= float(self.processing_turn_ttl_seconds):
|
|
1116
|
-
last_turn_to_enqueue = int(total_turns)
|
|
1117
|
-
|
|
1118
|
-
new_turns = []
|
|
1119
|
-
if completed_count > last_count:
|
|
1120
|
-
new_turns.extend(range(int(last_count) + 1, int(completed_count) + 1))
|
|
1121
|
-
if last_turn_to_enqueue and last_turn_to_enqueue > 0:
|
|
1122
|
-
new_turns.append(last_turn_to_enqueue)
|
|
1123
|
-
|
|
1124
|
-
if not new_turns:
|
|
1125
|
-
logger.debug(
|
|
1126
|
-
f"No new turns in {session_file.name} (count: {completed_count}), skipping idle enqueue"
|
|
1127
|
-
)
|
|
1128
|
-
self.last_final_commit_times[session_path] = current_time
|
|
1129
|
-
continue
|
|
1130
|
-
|
|
1131
|
-
logger.info(
|
|
1132
|
-
f"Session {session_file.name} idle for {time_since_change:.0f}s, enqueueing turns: {sorted(set(new_turns))}"
|
|
1133
|
-
)
|
|
1134
|
-
print(
|
|
1135
|
-
f"[Watcher] Session idle for {time_since_change:.0f}s - enqueueing final turns",
|
|
1136
|
-
file=sys.stderr,
|
|
1137
|
-
)
|
|
1138
|
-
|
|
1139
|
-
project_path = self._extract_project_path(session_file)
|
|
1140
|
-
if not project_path:
|
|
1141
|
-
logger.debug(
|
|
1142
|
-
f"Skipping enqueue for {session_file.name}: could not extract project path"
|
|
1143
|
-
)
|
|
1144
|
-
# Mark as attempted to avoid spamming logs
|
|
1145
|
-
self.last_final_commit_times[session_path] = current_time
|
|
1146
|
-
continue
|
|
1147
|
-
|
|
1148
|
-
enqueued_any = False
|
|
1149
|
-
agent_id = None
|
|
1150
|
-
if session_type == "codex":
|
|
1151
|
-
agent_id = self._agent_info_id_for_codex_session(session_file, db=db)
|
|
1152
|
-
terminal_id = self._terminal_id_for_codex_session(session_file)
|
|
1153
|
-
if terminal_id:
|
|
1154
|
-
try:
|
|
1155
|
-
db.insert_window_link(
|
|
1156
|
-
terminal_id=terminal_id,
|
|
1157
|
-
agent_id=agent_id,
|
|
1158
|
-
session_id=session_id,
|
|
1159
|
-
provider="codex",
|
|
1160
|
-
source="codex:watcher",
|
|
1161
|
-
ts=time.time(),
|
|
1162
|
-
)
|
|
1163
|
-
except Exception:
|
|
1164
|
-
pass
|
|
1165
|
-
for turn_number in sorted(set(new_turns)):
|
|
1166
|
-
try:
|
|
1167
|
-
db.enqueue_turn_summary_job( # type: ignore[attr-defined]
|
|
1168
|
-
session_file_path=session_file,
|
|
1169
|
-
workspace_path=project_path,
|
|
1170
|
-
turn_number=int(turn_number),
|
|
1171
|
-
session_type=session_type,
|
|
1172
|
-
agent_id=agent_id if agent_id else None,
|
|
1173
|
-
)
|
|
1174
|
-
enqueued_any = True
|
|
1175
|
-
except Exception as e:
|
|
1176
|
-
logger.warning(
|
|
1177
|
-
f"Failed to enqueue final turn_summary for {session_file.name} #{turn_number}: {e}"
|
|
1178
|
-
)
|
|
1179
|
-
|
|
1180
|
-
if enqueued_any:
|
|
1181
|
-
# Baseline follows completed_count (Claude excludes last turn by design).
|
|
1182
|
-
self.last_stop_reason_counts[session_path] = max(
|
|
1183
|
-
self.last_stop_reason_counts.get(session_path, 0),
|
|
1184
|
-
int(completed_count),
|
|
1185
|
-
)
|
|
1186
|
-
|
|
1187
|
-
self.last_final_commit_times[session_path] = current_time
|
|
1188
|
-
|
|
1189
|
-
except Exception as e:
|
|
1190
|
-
logger.warning(f"Error checking idle status for {session_path}: {e}")
|
|
1191
|
-
continue
|
|
1192
|
-
|
|
1193
|
-
except Exception as e:
|
|
1194
|
-
logger.error(f"Error in idle session check: {e}", exc_info=True)
|
|
1195
|
-
|
|
1196
1627
|
def _extract_project_path(self, session_file: Path) -> Optional[Path]:
|
|
1197
1628
|
"""
|
|
1198
1629
|
Extract project path (cwd) from session file.
|
|
@@ -1297,7 +1728,11 @@ class DialogueWatcher:
|
|
|
1297
1728
|
logger.error(f"Trigger error for {session_file.name}: {e}")
|
|
1298
1729
|
return 0
|
|
1299
1730
|
|
|
1300
|
-
async def check_for_changes(
|
|
1731
|
+
async def check_for_changes(
|
|
1732
|
+
self,
|
|
1733
|
+
current_sizes: Optional[Dict[str, int]] = None,
|
|
1734
|
+
current_mtimes: Optional[Dict[str, float]] = None,
|
|
1735
|
+
):
|
|
1301
1736
|
"""Check if any session file has been modified."""
|
|
1302
1737
|
try:
|
|
1303
1738
|
if current_sizes is None or current_mtimes is None:
|
|
@@ -1321,8 +1756,6 @@ class DialogueWatcher:
|
|
|
1321
1756
|
self._maybe_link_codex_terminal(Path(path))
|
|
1322
1757
|
except Exception:
|
|
1323
1758
|
pass
|
|
1324
|
-
# Reset idle final-commit attempt tracking for new files
|
|
1325
|
-
self.last_final_commit_times.pop(path, None)
|
|
1326
1759
|
continue
|
|
1327
1760
|
|
|
1328
1761
|
if size != old_size or (mtime is not None and mtime != old_mtime):
|
|
@@ -1330,8 +1763,6 @@ class DialogueWatcher:
|
|
|
1330
1763
|
logger.debug(
|
|
1331
1764
|
f"Session file changed: {Path(path).name} (size {old_size} -> {size} bytes)"
|
|
1332
1765
|
)
|
|
1333
|
-
# Any activity should reset idle final-commit attempts
|
|
1334
|
-
self.last_final_commit_times.pop(path, None)
|
|
1335
1766
|
|
|
1336
1767
|
if changed_files:
|
|
1337
1768
|
# Accumulate changed files instead of cancelling pending task
|
|
@@ -1354,7 +1785,7 @@ class DialogueWatcher:
|
|
|
1354
1785
|
|
|
1355
1786
|
# Update tracked sizes
|
|
1356
1787
|
self.last_session_sizes = current_sizes
|
|
1357
|
-
# Update tracked mtimes for change
|
|
1788
|
+
# Update tracked mtimes for change detection
|
|
1358
1789
|
self.last_session_mtimes = current_mtimes
|
|
1359
1790
|
|
|
1360
1791
|
except Exception as e:
|
|
@@ -1362,7 +1793,7 @@ class DialogueWatcher:
|
|
|
1362
1793
|
print(f"[Watcher] Error checking for changes: {e}", file=sys.stderr)
|
|
1363
1794
|
|
|
1364
1795
|
async def _debounced_commit_accumulated(self):
|
|
1365
|
-
"""Wait for debounce period, then enqueue
|
|
1796
|
+
"""Wait for debounce period, then enqueue per-session processing jobs."""
|
|
1366
1797
|
try:
|
|
1367
1798
|
# Wait for debounce period
|
|
1368
1799
|
await asyncio.sleep(self.debounce_delay)
|
|
@@ -1374,29 +1805,12 @@ class DialogueWatcher:
|
|
|
1374
1805
|
if not changed_files:
|
|
1375
1806
|
return
|
|
1376
1807
|
|
|
1377
|
-
logger.info(f"
|
|
1378
|
-
|
|
1379
|
-
# Check all changed files for new completed turns
|
|
1380
|
-
sessions_to_enqueue: list[tuple[Path, list[int]]] = []
|
|
1381
|
-
for session_file in changed_files:
|
|
1382
|
-
if not session_file.exists():
|
|
1383
|
-
continue
|
|
1384
|
-
# Best-effort: keep terminal bindings fresh (especially after watcher restarts).
|
|
1385
|
-
try:
|
|
1386
|
-
self._maybe_link_codex_terminal(session_file)
|
|
1387
|
-
except Exception:
|
|
1388
|
-
pass
|
|
1389
|
-
new_turns = self._get_new_completed_turn_numbers(session_file)
|
|
1390
|
-
if new_turns:
|
|
1391
|
-
sessions_to_enqueue.append((session_file, new_turns))
|
|
1392
|
-
|
|
1393
|
-
if not sessions_to_enqueue:
|
|
1394
|
-
return
|
|
1808
|
+
logger.info(f"Enqueueing session_process for {len(changed_files)} session(s)")
|
|
1395
1809
|
|
|
1396
1810
|
# Prefer processing the most recently modified sessions first
|
|
1397
1811
|
try:
|
|
1398
|
-
|
|
1399
|
-
key=lambda it: it
|
|
1812
|
+
changed_files.sort(
|
|
1813
|
+
key=lambda it: it.stat().st_mtime if it.exists() else 0,
|
|
1400
1814
|
reverse=True,
|
|
1401
1815
|
)
|
|
1402
1816
|
except Exception:
|
|
@@ -1406,58 +1820,24 @@ class DialogueWatcher:
|
|
|
1406
1820
|
|
|
1407
1821
|
db = get_database()
|
|
1408
1822
|
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
f"[Watcher] New completed turns detected in {session_file.name}: {new_turns}",
|
|
1413
|
-
file=sys.stderr,
|
|
1414
|
-
)
|
|
1415
|
-
|
|
1416
|
-
project_path = self._extract_project_path(session_file)
|
|
1417
|
-
if not project_path:
|
|
1418
|
-
logger.debug(
|
|
1419
|
-
f"Could not determine project path for {session_file.name}, skipping enqueue"
|
|
1420
|
-
)
|
|
1823
|
+
yield_often = len(changed_files) > 1
|
|
1824
|
+
for idx, session_file in enumerate(changed_files, start=1):
|
|
1825
|
+
if not session_file.exists():
|
|
1421
1826
|
continue
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
if terminal_id:
|
|
1430
|
-
try:
|
|
1431
|
-
db.insert_window_link(
|
|
1432
|
-
terminal_id=terminal_id,
|
|
1433
|
-
agent_id=agent_id,
|
|
1434
|
-
session_id=session_file.stem,
|
|
1435
|
-
provider="codex",
|
|
1436
|
-
source="codex:watcher",
|
|
1437
|
-
ts=time.time(),
|
|
1438
|
-
)
|
|
1439
|
-
except Exception:
|
|
1440
|
-
pass
|
|
1441
|
-
for turn_number in new_turns:
|
|
1442
|
-
try:
|
|
1443
|
-
db.enqueue_turn_summary_job( # type: ignore[attr-defined]
|
|
1444
|
-
session_file_path=session_file,
|
|
1445
|
-
workspace_path=project_path,
|
|
1446
|
-
turn_number=turn_number,
|
|
1447
|
-
session_type=session_type,
|
|
1448
|
-
agent_id=agent_id if agent_id else None,
|
|
1449
|
-
)
|
|
1450
|
-
enqueued_any = True
|
|
1451
|
-
except Exception as e:
|
|
1452
|
-
logger.warning(
|
|
1453
|
-
f"Failed to enqueue turn_summary for {session_file.name} #{turn_number}: {e}"
|
|
1454
|
-
)
|
|
1455
|
-
|
|
1456
|
-
if enqueued_any:
|
|
1457
|
-
session_path = str(session_file)
|
|
1458
|
-
self.last_stop_reason_counts[session_path] = max(
|
|
1459
|
-
self.last_stop_reason_counts.get(session_path, 0), max(new_turns)
|
|
1827
|
+
try:
|
|
1828
|
+
db.enqueue_session_process_job( # type: ignore[attr-defined]
|
|
1829
|
+
session_file_path=session_file,
|
|
1830
|
+
session_id=session_file.stem,
|
|
1831
|
+
workspace_path=None,
|
|
1832
|
+
session_type=self._detect_session_type(session_file),
|
|
1833
|
+
source_event="poll",
|
|
1460
1834
|
)
|
|
1835
|
+
except Exception as e:
|
|
1836
|
+
logger.warning(
|
|
1837
|
+
f"Failed to enqueue session_process for {session_file.name}: {e}"
|
|
1838
|
+
)
|
|
1839
|
+
if yield_often:
|
|
1840
|
+
await asyncio.sleep(0)
|
|
1461
1841
|
|
|
1462
1842
|
except asyncio.CancelledError:
|
|
1463
1843
|
pass
|
|
@@ -1466,7 +1846,7 @@ class DialogueWatcher:
|
|
|
1466
1846
|
print(f"[Watcher] Error in debounced enqueue: {e}", file=sys.stderr)
|
|
1467
1847
|
|
|
1468
1848
|
async def _debounced_commit(self, changed_files: list):
|
|
1469
|
-
"""Wait for debounce period, then enqueue
|
|
1849
|
+
"""Wait for debounce period, then enqueue per-session processing jobs.
|
|
1470
1850
|
|
|
1471
1851
|
DEPRECATED: Use _debounced_commit_accumulated instead.
|
|
1472
1852
|
Kept for backwards compatibility.
|
|
@@ -1485,48 +1865,17 @@ class DialogueWatcher:
|
|
|
1485
1865
|
if not session_file.exists():
|
|
1486
1866
|
continue
|
|
1487
1867
|
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1868
|
+
try:
|
|
1869
|
+
db.enqueue_session_process_job( # type: ignore[attr-defined]
|
|
1870
|
+
session_file_path=session_file,
|
|
1871
|
+
session_id=session_file.stem,
|
|
1872
|
+
workspace_path=None,
|
|
1873
|
+
session_type=self._detect_session_type(session_file),
|
|
1874
|
+
source_event="poll",
|
|
1875
|
+
)
|
|
1876
|
+
except Exception:
|
|
1494
1877
|
continue
|
|
1495
1878
|
|
|
1496
|
-
for turn_number in new_turns:
|
|
1497
|
-
try:
|
|
1498
|
-
session_type = self._detect_session_type(session_file)
|
|
1499
|
-
agent_id = None
|
|
1500
|
-
if session_type == "codex":
|
|
1501
|
-
agent_id = self._agent_info_id_for_codex_session(session_file, db=db)
|
|
1502
|
-
terminal_id = self._terminal_id_for_codex_session(session_file)
|
|
1503
|
-
if terminal_id:
|
|
1504
|
-
try:
|
|
1505
|
-
db.insert_window_link(
|
|
1506
|
-
terminal_id=terminal_id,
|
|
1507
|
-
agent_id=agent_id,
|
|
1508
|
-
session_id=session_file.stem,
|
|
1509
|
-
provider="codex",
|
|
1510
|
-
source="codex:watcher",
|
|
1511
|
-
ts=time.time(),
|
|
1512
|
-
)
|
|
1513
|
-
except Exception:
|
|
1514
|
-
pass
|
|
1515
|
-
db.enqueue_turn_summary_job( # type: ignore[attr-defined]
|
|
1516
|
-
session_file_path=session_file,
|
|
1517
|
-
workspace_path=project_path,
|
|
1518
|
-
turn_number=turn_number,
|
|
1519
|
-
session_type=session_type,
|
|
1520
|
-
agent_id=agent_id if agent_id else None,
|
|
1521
|
-
)
|
|
1522
|
-
except Exception:
|
|
1523
|
-
continue
|
|
1524
|
-
|
|
1525
|
-
session_path = str(session_file)
|
|
1526
|
-
self.last_stop_reason_counts[session_path] = max(
|
|
1527
|
-
self.last_stop_reason_counts.get(session_path, 0), max(new_turns)
|
|
1528
|
-
)
|
|
1529
|
-
|
|
1530
1879
|
except asyncio.CancelledError:
|
|
1531
1880
|
# Task was cancelled because a newer change was detected
|
|
1532
1881
|
pass
|