aline-ai 0.7.3__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.
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 idle check)
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(self) -> tuple[list[Path], Dict[str, int], Dict[str, float]]:
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 .codex_home import codex_home_owner_from_session_file
416
- except Exception:
417
- return None
788
+ from datetime import datetime, timezone
418
789
 
419
- try:
420
- owner = codex_home_owner_from_session_file(session_file)
421
- except Exception:
422
- owner = None
423
- if not owner or owner[0] != "terminal":
424
- return None
425
- return owner[1]
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
- # Catch up any missed turns using persistent state
576
- await self._catch_up_uncommitted_turns()
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
- # Single stat pass per cycle (Layer 2)
589
- cycle_sessions, cycle_sizes, cycle_mtimes = self._get_cycle_session_stats()
590
- self._last_cycle_sizes = cycle_sizes
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
- # Check for idle sessions that need final commit
596
- await self._check_idle_sessions_for_final_commit(cycle_sessions, cycle_mtimes)
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 and Path(transcript_path).exists():
645
- session_file = Path(transcript_path)
1154
+ if transcript_path:
1155
+ candidate = Path(transcript_path)
1156
+ if candidate.exists():
1157
+ session_file = candidate
646
1158
  elif session_id:
647
- session_file = self._find_session_by_id(session_id)
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
- # Determine project path
651
- if project_dir and Path(project_dir).exists():
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
- # Enqueue durable job for worker (no LLM work in watcher process).
663
- from .db import get_database
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
- logger.info(
690
- f"Enqueued turn_summary via Stop hook: {session_id} turn {target_turn} ({project_path.name})"
691
- )
692
- print(
693
- f"[Watcher] Enqueued turn_summary via Stop hook (turn {target_turn})",
694
- file=sys.stderr,
695
- )
696
- else:
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"Could not determine project path for session {session_id}"
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
- # Delete the signal only after enqueue succeeds.
704
- signal_file.unlink(missing_ok=True)
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 or idle fallback.
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(self, current_sizes: Optional[Dict[str, int]] = None, current_mtimes: Optional[Dict[str, float]] = None):
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 + idle detection
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 jobs for accumulated changed files."""
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"Processing {len(changed_files)} accumulated session file(s)")
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
- sessions_to_enqueue.sort(
1399
- key=lambda it: it[0].stat().st_mtime if it[0].exists() else 0,
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
- for session_file, new_turns in sessions_to_enqueue:
1410
- logger.info(f"New completed turns detected in {session_file.name}: {new_turns}")
1411
- print(
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
- enqueued_any = False
1424
- session_type = self._detect_session_type(session_file)
1425
- agent_id = None
1426
- if session_type == "codex":
1427
- agent_id = self._agent_info_id_for_codex_session(session_file, db=db)
1428
- terminal_id = self._terminal_id_for_codex_session(session_file)
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 jobs for changed files.
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
- new_turns = self._get_new_completed_turn_numbers(session_file)
1489
- if not new_turns:
1490
- continue
1491
-
1492
- project_path = self._extract_project_path(session_file)
1493
- if not project_path:
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