@misterhuydo/sentinel 1.5.63 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.cairn/.hint-lock +1 -1
  2. package/.cairn/session.json +2 -2
  3. package/package.json +1 -1
  4. package/python/scripts/__pycache__/fix_ask_codebase_context.cpython-311.pyc +0 -0
  5. package/python/scripts/__pycache__/fix_ask_codebase_stdin.cpython-311.pyc +0 -0
  6. package/python/scripts/__pycache__/fix_chain_slack.cpython-311.pyc +0 -0
  7. package/python/scripts/__pycache__/fix_fstring.cpython-311.pyc +0 -0
  8. package/python/scripts/__pycache__/fix_knowledge_cache.cpython-311.pyc +0 -0
  9. package/python/scripts/__pycache__/fix_knowledge_cache_staleness.cpython-311.pyc +0 -0
  10. package/python/scripts/__pycache__/fix_merge_confirm.cpython-311.pyc +0 -0
  11. package/python/scripts/__pycache__/fix_permission_messages.cpython-311.pyc +0 -0
  12. package/python/scripts/__pycache__/fix_pr_check_head_detect.cpython-311.pyc +0 -0
  13. package/python/scripts/__pycache__/fix_pr_msg_newlines.cpython-311.pyc +0 -0
  14. package/python/scripts/__pycache__/fix_pr_tracking_boss.cpython-311.pyc +0 -0
  15. package/python/scripts/__pycache__/fix_pr_tracking_db.cpython-311.pyc +0 -0
  16. package/python/scripts/__pycache__/fix_pr_tracking_main.cpython-311.pyc +0 -0
  17. package/python/scripts/__pycache__/fix_project_isolation.cpython-311.pyc +0 -0
  18. package/python/scripts/__pycache__/fix_system_prompt.cpython-311.pyc +0 -0
  19. package/python/scripts/__pycache__/fix_two_bugs.cpython-311.pyc +0 -0
  20. package/python/scripts/__pycache__/patch_chain_release.cpython-311.pyc +0 -0
  21. package/python/sentinel/__init__.py +1 -1
  22. package/python/sentinel/__pycache__/__init__.cpython-311.pyc +0 -0
  23. package/python/sentinel/__pycache__/cairn_client.cpython-311.pyc +0 -0
  24. package/python/sentinel/__pycache__/cicd_trigger.cpython-311.pyc +0 -0
  25. package/python/sentinel/__pycache__/config_loader.cpython-311.pyc +0 -0
  26. package/python/sentinel/__pycache__/dependency_manager.cpython-311.pyc +0 -0
  27. package/python/sentinel/__pycache__/dev_watcher.cpython-311.pyc +0 -0
  28. package/python/sentinel/__pycache__/fix_engine.cpython-311.pyc +0 -0
  29. package/python/sentinel/__pycache__/git_manager.cpython-311.pyc +0 -0
  30. package/python/sentinel/__pycache__/health_checker.cpython-311.pyc +0 -0
  31. package/python/sentinel/__pycache__/issue_watcher.cpython-311.pyc +0 -0
  32. package/python/sentinel/__pycache__/log_fetcher.cpython-311.pyc +0 -0
  33. package/python/sentinel/__pycache__/log_parser.cpython-311.pyc +0 -0
  34. package/python/sentinel/__pycache__/log_syncer.cpython-311.pyc +0 -0
  35. package/python/sentinel/__pycache__/main.cpython-311.pyc +0 -0
  36. package/python/sentinel/__pycache__/notify.cpython-311.pyc +0 -0
  37. package/python/sentinel/__pycache__/repo_router.cpython-311.pyc +0 -0
  38. package/python/sentinel/__pycache__/repo_task_engine.cpython-311.pyc +0 -0
  39. package/python/sentinel/__pycache__/reporter.cpython-311.pyc +0 -0
  40. package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
  41. package/python/sentinel/__pycache__/sentinel_dev.cpython-311.pyc +0 -0
  42. package/python/sentinel/__pycache__/slack_bot.cpython-311.pyc +0 -0
  43. package/python/sentinel/__pycache__/state_store.cpython-311.pyc +0 -0
  44. package/python/sentinel/cairn_client.py +30 -11
  45. package/python/sentinel/fix_engine.py +200 -46
  46. package/python/sentinel/git_manager.py +335 -0
  47. package/python/sentinel/main.py +320 -6
  48. package/python/sentinel/state_store.py +121 -0
  49. package/python/tests/test_cairn_client.py +72 -0
  50. package/python/tests/test_fix_engine_cmd.py +53 -0
  51. package/python/tests/test_fix_engine_json.py +95 -0
  52. package/python/tests/test_fix_engine_prompt.py +93 -0
  53. package/python/tests/test_multi_repo_apply.py +254 -0
  54. package/python/tests/test_multi_repo_publish.py +175 -0
  55. package/python/tests/test_patch_parser.py +250 -0
  56. package/python/tests/test_project_lock.py +85 -0
  57. package/python/tests/test_state_store.py +87 -0
@@ -20,10 +20,13 @@ import sys
20
20
  from datetime import datetime, timezone
21
21
  from pathlib import Path
22
22
 
23
- from .cairn_client import ensure_installed as cairn_installed, index_repo
23
+ from .cairn_client import ensure_installed as cairn_installed, index_repo, init_project_root
24
24
  from .config_loader import ConfigLoader, SentinelConfig, resolve_auto_commit, resolve_auto_release
25
25
  from .fix_engine import generate_fix
26
- from .git_manager import apply_and_commit, publish, _git_env, MissingToolError, MavenAuthError, poll_open_prs
26
+ from .git_manager import (
27
+ apply_and_commit, publish, apply_and_commit_multi, publish_multi,
28
+ _git_env, MissingToolError, MavenAuthError, poll_open_prs,
29
+ )
27
30
  from .cicd_trigger import trigger as cicd_trigger
28
31
  from .log_fetcher import fetch_all
29
32
  from .log_parser import parse_all, scan_all_for_markers, ErrorEvent
@@ -45,6 +48,18 @@ logger = logging.getLogger("sentinel")
45
48
 
46
49
  _report_requested = False
47
50
 
51
+ # Per-project asyncio.Lock — serialises fix generation + apply within one
52
+ # project so two concurrent claude sessions never race on the working tree.
53
+ # Keyed by cfg.project_name; populated lazily on first acquire.
54
+ _project_locks: dict[str, "asyncio.Lock"] = {}
55
+
56
+
57
+ def _project_lock(project_name: str) -> "asyncio.Lock":
58
+ """Return (and lazily create) the asyncio.Lock for a project."""
59
+ if project_name not in _project_locks:
60
+ _project_locks[project_name] = asyncio.Lock()
61
+ return _project_locks[project_name]
62
+
48
63
 
49
64
  def _on_sigusr1(*_):
50
65
  global _report_requested
@@ -271,10 +286,37 @@ async def _handle_error(event: ErrorEvent, cfg_loader: ConfigLoader, store: Stat
271
286
  logger.debug("Fix already attempted recently for %s", event.fingerprint)
272
287
  return
273
288
 
274
- # ── Generate fix ──────────────────────────────────────────────────────────
289
+ # Per-project lock — serialise fix generation+apply+publish so two
290
+ # concurrent claude sessions never race on the working tree of any repo.
291
+ async with _project_lock(sentinel.project_name or "_default"):
292
+ await _generate_apply_publish(
293
+ event, repo, sentinel, store, cfg_loader,
294
+ _progress, auto_commit, auto_release,
295
+ )
296
+
297
+
298
+ async def _generate_apply_publish(
299
+ event: ErrorEvent,
300
+ repo,
301
+ sentinel: SentinelConfig,
302
+ store: StateStore,
303
+ cfg_loader: ConfigLoader,
304
+ _progress,
305
+ auto_commit: bool,
306
+ auto_release: bool,
307
+ ):
308
+ """Generate the fix and apply it via the multi-repo flow (with single-repo fallback).
309
+
310
+ A patch from claude with `repos/<name>/...` paths goes through the
311
+ multi-repo apply (atomic dry-run, per-repo commit). If the patch has no
312
+ such prefix (legacy single-repo), we fall back to apply_and_commit/publish.
313
+ """
275
314
  _progress(":brain: Analyzing with Claude Code...")
276
315
  patches_dir = Path(sentinel.workspace_dir).resolve() / "patches"
277
- status, patch_path, marker = generate_fix(event, repo, sentinel, patches_dir, store)
316
+ all_repos = list(cfg_loader.repos.values())
317
+ status, patch_path, marker = generate_fix(
318
+ event, repo, sentinel, patches_dir, store, all_repos=all_repos,
319
+ )
278
320
 
279
321
  if status != "patch" or patch_path is None:
280
322
  outcome = "skipped" if status in ("skip", "needs_human") else "failed"
@@ -295,8 +337,137 @@ async def _handle_error(event: ErrorEvent, cfg_loader: ConfigLoader, store: Stat
295
337
  })
296
338
  return
297
339
 
298
- # ── Apply fix ─────────────────────────────────────────────────────────────
340
+ # ── Try multi-repo apply first; fall back to single-repo on legacy patches ──
299
341
  _progress(":gear: Applying patch and running tests...")
342
+ multi_results = apply_and_commit_multi(event, patch_path, all_repos, sentinel)
343
+
344
+ if not multi_results:
345
+ # Legacy single-repo patch (no `repos/<name>/` prefix) — use existing flow.
346
+ logger.info(
347
+ "main: patch for %s has no repos/<name>/ prefix — falling back to single-repo apply",
348
+ event.fingerprint[:8],
349
+ )
350
+ await _apply_publish_single(
351
+ event, repo, sentinel, store, cfg_loader,
352
+ _progress, auto_commit, auto_release, patch_path, marker,
353
+ )
354
+ return
355
+
356
+ # ── Multi-repo flow ────────────────────────────────────────────────────
357
+ multi_results = publish_multi(event, multi_results, sentinel)
358
+
359
+ committed = [r for r in multi_results if r["status"] == "committed"]
360
+ failed = [r for r in multi_results if r["status"] != "committed"]
361
+
362
+ # Record per-repo state in fix_repos for Boss/reporter visibility.
363
+ for r in multi_results:
364
+ pr_state = "open" if r.get("pr_url") else ("merged" if r["status"] == "committed" and auto_commit else "")
365
+ try:
366
+ store.record_fix_repo(
367
+ event.fingerprint, r["repo_name"],
368
+ branch=r.get("branch", ""), commit_hash=r.get("commit_hash", ""),
369
+ pr_url=r.get("pr_url", ""),
370
+ pr_state=pr_state if r["status"] == "committed" else r["status"],
371
+ apply_order=r.get("apply_order", 0),
372
+ )
373
+ except Exception as _se:
374
+ logger.warning("record_fix_repo failed for %s: %s", r["repo_name"], _se)
375
+
376
+ # Summary fixes-table row uses the primary repo (where the error originated)
377
+ # so existing single-row queries (get_open_prs, recent fixes) still work.
378
+ primary = next((r for r in multi_results if r["repo_name"] == repo.repo_name), None)
379
+ if primary and primary["status"] == "committed":
380
+ store.record_fix(
381
+ event.fingerprint,
382
+ "applied" if auto_commit else "pending",
383
+ patch_path=str(patch_path),
384
+ commit_hash=primary["commit_hash"],
385
+ branch=primary.get("branch", ""),
386
+ pr_url=primary.get("pr_url", ""),
387
+ repo_name=repo.repo_name,
388
+ sentinel_marker=marker,
389
+ )
390
+ else:
391
+ store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
392
+
393
+ # ── Surface the result ─────────────────────────────────────────────────
394
+ if not committed:
395
+ reasons = "; ".join(f"{r['repo_name']}: {r['reason']}" for r in failed)
396
+ _progress(f":x: Multi-repo fix aborted — {reasons[:300]}")
397
+ send_failure_notification(sentinel, {
398
+ "source": event.source,
399
+ "message": event.message,
400
+ "repo_name": repo.repo_name,
401
+ "reason": f"Multi-repo apply failed: {reasons[:400]}",
402
+ "body": event.full_text()[:500],
403
+ })
404
+ return
405
+
406
+ if failed:
407
+ _progress(
408
+ f":warning: Multi-repo fix PARTIAL — committed in "
409
+ f"{', '.join(r['repo_name'] for r in committed)}; "
410
+ f"failed in {', '.join(r['repo_name'] for r in failed)}"
411
+ )
412
+
413
+ pr_lines = []
414
+ for r in committed:
415
+ if r.get("pr_url"):
416
+ pr_lines.append(f" • `{r['repo_name']}` → {r['pr_url']}")
417
+ else:
418
+ pr_lines.append(f" • `{r['repo_name']}` → pushed to `{r.get('branch','main')}` "
419
+ f"(`{r.get('commit_hash','')[:8]}`)")
420
+ if pr_lines:
421
+ _progress(":white_check_mark: Fix complete:\n" + "\n".join(pr_lines))
422
+
423
+ # Pending release tracking for auto_commit-without-auto_release per repo.
424
+ if auto_commit and not auto_release:
425
+ for r in committed:
426
+ if r.get("commit_hash") and r.get("branch"):
427
+ store.add_pending_release(
428
+ r["repo_name"], r["commit_hash"], r["branch"],
429
+ description=event.message[:120],
430
+ fingerprint=event.fingerprint,
431
+ )
432
+
433
+ # Single fix notification using the primary repo's row (back-compat with reporter).
434
+ if primary and primary["status"] == "committed":
435
+ send_fix_notification(sentinel, {
436
+ "source": event.source,
437
+ "severity": event.severity,
438
+ "fingerprint": event.fingerprint,
439
+ "first_seen": str(event.timestamp),
440
+ "message": event.message,
441
+ "stack_trace": getattr(event, "stack_trace", ""),
442
+ "repo_name": repo.repo_name,
443
+ "commit_hash": primary.get("commit_hash", ""),
444
+ "branch": primary.get("branch", ""),
445
+ "pr_url": primary.get("pr_url", ""),
446
+ "auto_commit": auto_commit,
447
+ "files_changed": [],
448
+ })
449
+
450
+ # CI/CD trigger — only fire for the primary repo for now (multi-repo cascade is v2).
451
+ if auto_commit and auto_release and primary and primary["status"] == "committed":
452
+ ok = cicd_trigger(repo, store, event.fingerprint)
453
+ if ok and repo.cicd_type.lower() in ("jenkins_release", "jenkins-release"):
454
+ _run_cascade(repo, sentinel, cfg_loader)
455
+
456
+
457
+ async def _apply_publish_single(
458
+ event: ErrorEvent,
459
+ repo,
460
+ sentinel: SentinelConfig,
461
+ store: StateStore,
462
+ cfg_loader: ConfigLoader,
463
+ _progress,
464
+ auto_commit: bool,
465
+ auto_release: bool,
466
+ patch_path: Path,
467
+ marker: str,
468
+ ):
469
+ """Legacy single-repo apply+publish flow — used when the patch has no
470
+ `repos/<name>/` prefix (older claude output, manual issues, etc.)."""
300
471
  try:
301
472
  commit_status, commit_hash = apply_and_commit(event, patch_path, repo, sentinel)
302
473
  except MavenAuthError as e:
@@ -457,12 +628,15 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
457
628
  try:
458
629
  patches_dir = Path(sentinel.workspace_dir).resolve() / "patches"
459
630
  _loop = asyncio.get_event_loop()
631
+ all_repos = list(cfg_loader.repos.values())
460
632
 
461
633
  # Run blocking subprocess work in thread executor so Boss stays responsive.
462
634
  # _progress is thread-safe (HTTP POST) so pass it as streaming callback.
463
635
  _progress(":brain: Analyzing with Claude Code...")
464
636
  status, patch_path, marker = await _loop.run_in_executor(
465
- None, generate_fix, event, repo, sentinel, patches_dir, store, _progress
637
+ None,
638
+ lambda: generate_fix(event, repo, sentinel, patches_dir, store,
639
+ _progress, all_repos),
466
640
  )
467
641
 
468
642
  submitter_uid = getattr(event, "submitter_user_id", "")
@@ -481,6 +655,133 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
481
655
  return {"submitter": submitter_uid, "repo_name": repo.repo_name,
482
656
  "status": "blocked", "summary": reason_text[:120], "pr_url": ""}
483
657
 
658
+ # ── Try multi-repo apply first; fall back to single-repo on legacy patches ──
659
+ _progress(f":mag: Patch generated — applying & testing...")
660
+ multi_results = await _loop.run_in_executor(
661
+ None, apply_and_commit_multi, event, patch_path, all_repos, sentinel,
662
+ )
663
+
664
+ if multi_results:
665
+ multi_results = await _loop.run_in_executor(
666
+ None, publish_multi, event, multi_results, sentinel,
667
+ )
668
+
669
+ committed = [r for r in multi_results if r["status"] == "committed"]
670
+ failed = [r for r in multi_results if r["status"] != "committed"]
671
+
672
+ for r in multi_results:
673
+ pr_state = "open" if r.get("pr_url") else (
674
+ "merged" if r["status"] == "committed" and auto_commit else ""
675
+ )
676
+ try:
677
+ store.record_fix_repo(
678
+ event.fingerprint, r["repo_name"],
679
+ branch=r.get("branch", ""), commit_hash=r.get("commit_hash", ""),
680
+ pr_url=r.get("pr_url", ""),
681
+ pr_state=pr_state if r["status"] == "committed" else r["status"],
682
+ apply_order=r.get("apply_order", 0),
683
+ )
684
+ except Exception as _se:
685
+ logger.warning("record_fix_repo failed for %s: %s", r["repo_name"], _se)
686
+
687
+ primary = next((r for r in multi_results if r["repo_name"] == repo.repo_name), None)
688
+ if primary and primary["status"] == "committed":
689
+ store.record_fix(
690
+ event.fingerprint,
691
+ "applied" if auto_commit else "pending",
692
+ patch_path=str(patch_path),
693
+ commit_hash=primary["commit_hash"],
694
+ branch=primary.get("branch", ""),
695
+ pr_url=primary.get("pr_url", ""),
696
+ repo_name=repo.repo_name,
697
+ sentinel_marker=marker,
698
+ )
699
+ else:
700
+ store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
701
+
702
+ if not committed:
703
+ reasons = "; ".join(f"{r['repo_name']}: {r['reason']}" for r in failed)
704
+ _progress(f":x: Multi-repo fix aborted — {reasons[:300]}")
705
+ notify_fix_blocked(sentinel, event.source, event.message,
706
+ reason=f"Multi-repo apply failed: {reasons[:400]}",
707
+ repo_name=repo.repo_name,
708
+ submitter_user_id=submitter_uid,
709
+ body=getattr(event, "body", ""))
710
+ mark_done(event.issue_file)
711
+ return {"submitter": submitter_uid, "repo_name": repo.repo_name,
712
+ "status": "blocked", "summary": "Multi-repo apply failed", "pr_url": ""}
713
+
714
+ if failed:
715
+ _progress(
716
+ f":warning: Multi-repo fix PARTIAL — committed in "
717
+ f"{', '.join(r['repo_name'] for r in committed)}; "
718
+ f"failed in {', '.join(r['repo_name'] for r in failed)}"
719
+ )
720
+
721
+ pr_lines = []
722
+ for r in committed:
723
+ if r.get("pr_url"):
724
+ pr_lines.append(f" • `{r['repo_name']}` → {r['pr_url']}")
725
+ else:
726
+ pr_lines.append(
727
+ f" • `{r['repo_name']}` → pushed to `{r.get('branch','main')}` "
728
+ f"(`{r.get('commit_hash','')[:8]}`)"
729
+ )
730
+ if pr_lines:
731
+ _progress(":white_check_mark: Fix complete:\n" + "\n".join(pr_lines))
732
+
733
+ if auto_commit and not auto_release:
734
+ for r in committed:
735
+ if r.get("commit_hash") and r.get("branch"):
736
+ store.add_pending_release(
737
+ r["repo_name"], r["commit_hash"], r["branch"],
738
+ description=event.message[:120],
739
+ fingerprint=event.fingerprint,
740
+ )
741
+
742
+ if primary and primary["status"] == "committed":
743
+ send_fix_notification(sentinel, {
744
+ "source": event.source,
745
+ "severity": "ERROR",
746
+ "fingerprint": event.fingerprint,
747
+ "first_seen": event.timestamp,
748
+ "message": event.message,
749
+ "stack_trace": event.body,
750
+ "repo_name": repo.repo_name,
751
+ "commit_hash": primary.get("commit_hash", ""),
752
+ "branch": primary.get("branch", ""),
753
+ "pr_url": primary.get("pr_url", ""),
754
+ "auto_commit": auto_commit,
755
+ "files_changed": [],
756
+ })
757
+ notify_fix_applied(
758
+ sentinel, event.source, event.message,
759
+ repo_name=repo.repo_name,
760
+ branch=primary.get("branch", ""),
761
+ pr_url=primary.get("pr_url", ""),
762
+ submitter_user_id=submitter_uid,
763
+ origin_channel=_origin_channel,
764
+ )
765
+
766
+ mark_done(event.issue_file)
767
+
768
+ if auto_commit and auto_release and primary and primary["status"] == "committed":
769
+ ok = cicd_trigger(repo, store, event.fingerprint)
770
+ if ok:
771
+ _progress(f":rocket: Release triggered via {repo.cicd_type}")
772
+ if ok and repo.cicd_type.lower() in ("jenkins_release", "jenkins-release"):
773
+ _run_cascade(repo, sentinel, cfg_loader)
774
+
775
+ return {"submitter": submitter_uid, "repo_name": repo.repo_name,
776
+ "status": "done" if not failed else "partial",
777
+ "summary": event.message[:120],
778
+ "pr_url": primary.get("pr_url", "") if primary else ""}
779
+
780
+ # Legacy single-repo patch (no `repos/<name>/` prefix) — existing flow below.
781
+ logger.info(
782
+ "_handle_issue: patch %s has no repos/<name>/ prefix — falling back to single-repo apply",
783
+ event.fingerprint[:8],
784
+ )
484
785
  _progress(f":mag: Patch generated — running tests (`{repo.repo_name}`)...")
485
786
  commit_status, commit_hash = await _loop.run_in_executor(
486
787
  None, apply_and_commit, event, patch_path, repo, sentinel
@@ -823,6 +1124,19 @@ async def _startup_checks(cfg_loader: ConfigLoader) -> dict:
823
1124
  if not cairn_installed():
824
1125
  results["warnings"].append("Cairn not found — run: npm install -g @misterhuydo/cairn-mcp")
825
1126
 
1127
+ # Initialise the project-root .cairn/ so child sub-repos federate up to it.
1128
+ # Once the project root + every sub-repo has its own .cairn/, Claude can
1129
+ # see across all repos via Cairn's parent walk-up — required for multi-repo
1130
+ # fix generation to work.
1131
+ project_root = str(Path(cfg_loader.sentinel.workspace_dir).parent)
1132
+ if init_project_root(project_root):
1133
+ logger.info("Cairn federation root initialised at %s", project_root)
1134
+ else:
1135
+ results["warnings"].append(
1136
+ f"Cairn project-root init failed at {project_root} — multi-repo "
1137
+ "visibility may be degraded. See logs."
1138
+ )
1139
+
826
1140
  for name, repo in cfg_loader.repos.items():
827
1141
  local = Path(repo.local_path)
828
1142
  if not local.exists():
@@ -87,6 +87,30 @@ class StateStore:
87
87
  fingerprint TEXT, -- error/issue fingerprint that triggered it
88
88
  committed_at TEXT NOT NULL
89
89
  );
90
+
91
+ -- One fingerprint can produce changes in multiple repos (cross-repo fix).
92
+ -- Each row tracks the per-repo branch / commit / PR for one such fingerprint.
93
+ CREATE TABLE IF NOT EXISTS fix_repos (
94
+ fingerprint TEXT NOT NULL,
95
+ repo_name TEXT NOT NULL,
96
+ branch TEXT,
97
+ commit_hash TEXT,
98
+ pr_url TEXT,
99
+ pr_state TEXT, -- open|merged|closed|failed
100
+ apply_order INTEGER DEFAULT 0,
101
+ timestamp TEXT NOT NULL,
102
+ PRIMARY KEY (fingerprint, repo_name)
103
+ );
104
+
105
+ -- Long-lived `claude --print --resume <id>` sessions, one per project.
106
+ -- Lets cross-task prompt cache hit and gives Claude continuous context.
107
+ CREATE TABLE IF NOT EXISTS claude_sessions (
108
+ project_name TEXT PRIMARY KEY,
109
+ session_id TEXT NOT NULL,
110
+ last_used TEXT NOT NULL,
111
+ total_cost_usd REAL NOT NULL DEFAULT 0,
112
+ turn_count INTEGER NOT NULL DEFAULT 0
113
+ );
90
114
  """)
91
115
  self._migrate()
92
116
  logger.debug("StateStore initialised at %s", self.db_path)
@@ -276,6 +300,103 @@ class StateStore:
276
300
  ).fetchone()
277
301
  return row is not None
278
302
 
303
+ # ── Multi-repo fix tracking (one fingerprint → many repos) ────────────────
304
+
305
+ def record_fix_repo(
306
+ self,
307
+ fingerprint: str,
308
+ repo_name: str,
309
+ branch: str = "",
310
+ commit_hash: str = "",
311
+ pr_url: str = "",
312
+ pr_state: str = "",
313
+ apply_order: int = 0,
314
+ ) -> None:
315
+ """Record (or replace) the per-repo branch/commit/PR for a multi-repo fix."""
316
+ with self._conn() as conn:
317
+ conn.execute(
318
+ "INSERT INTO fix_repos (fingerprint, repo_name, branch, commit_hash, "
319
+ "pr_url, pr_state, apply_order, timestamp) VALUES (?,?,?,?,?,?,?,?) "
320
+ "ON CONFLICT(fingerprint, repo_name) DO UPDATE SET "
321
+ "branch=excluded.branch, commit_hash=excluded.commit_hash, "
322
+ "pr_url=excluded.pr_url, pr_state=excluded.pr_state, "
323
+ "apply_order=excluded.apply_order, timestamp=excluded.timestamp",
324
+ (fingerprint, repo_name, branch, commit_hash,
325
+ pr_url, pr_state, apply_order, _now()),
326
+ )
327
+
328
+ def update_fix_repo_state(
329
+ self,
330
+ fingerprint: str,
331
+ repo_name: str,
332
+ pr_state: str = "",
333
+ commit_hash: str = "",
334
+ ) -> None:
335
+ """Patch pr_state and/or commit_hash on an existing fix_repos row."""
336
+ sets, args = [], []
337
+ if pr_state:
338
+ sets.append("pr_state=?"); args.append(pr_state)
339
+ if commit_hash:
340
+ sets.append("commit_hash=?"); args.append(commit_hash)
341
+ if not sets:
342
+ return
343
+ args.extend([fingerprint, repo_name])
344
+ with self._conn() as conn:
345
+ conn.execute(
346
+ f"UPDATE fix_repos SET {', '.join(sets)} "
347
+ "WHERE fingerprint=? AND repo_name=?", args,
348
+ )
349
+
350
+ def get_fix_repos(self, fingerprint: str) -> list[dict]:
351
+ """Return all per-repo rows for a fingerprint, ordered by apply_order then repo_name."""
352
+ with self._conn() as conn:
353
+ rows = conn.execute(
354
+ "SELECT * FROM fix_repos WHERE fingerprint=? "
355
+ "ORDER BY apply_order, repo_name",
356
+ (fingerprint,),
357
+ ).fetchall()
358
+ return [dict(r) for r in rows]
359
+
360
+ # ── Per-project Claude session tracking (for `claude --resume <id>`) ──────
361
+
362
+ def get_claude_session(self, project_name: str) -> dict | None:
363
+ """Return the saved {session_id, last_used, total_cost_usd, turn_count} or None."""
364
+ with self._conn() as conn:
365
+ row = conn.execute(
366
+ "SELECT session_id, last_used, total_cost_usd, turn_count "
367
+ "FROM claude_sessions WHERE project_name=?",
368
+ (project_name,),
369
+ ).fetchone()
370
+ return dict(row) if row else None
371
+
372
+ def set_claude_session(
373
+ self,
374
+ project_name: str,
375
+ session_id: str,
376
+ cost_delta: float = 0.0,
377
+ ) -> None:
378
+ """Upsert the session id; accumulates cost + turn count across calls."""
379
+ with self._conn() as conn:
380
+ conn.execute(
381
+ "INSERT INTO claude_sessions "
382
+ "(project_name, session_id, last_used, total_cost_usd, turn_count) "
383
+ "VALUES (?, ?, ?, ?, 1) "
384
+ "ON CONFLICT(project_name) DO UPDATE SET "
385
+ "session_id=excluded.session_id, "
386
+ "last_used=excluded.last_used, "
387
+ "total_cost_usd=total_cost_usd + excluded.total_cost_usd, "
388
+ "turn_count=turn_count + 1",
389
+ (project_name, session_id, _now(), cost_delta),
390
+ )
391
+
392
+ def clear_claude_session(self, project_name: str) -> None:
393
+ """Drop the saved session id for a project (forces a fresh session next call)."""
394
+ with self._conn() as conn:
395
+ conn.execute(
396
+ "DELETE FROM claude_sessions WHERE project_name=?",
397
+ (project_name,),
398
+ )
399
+
279
400
  def mark_marker_seen(self, marker: str) -> dict | None:
280
401
  """Record that a SENTINEL marker appeared in production logs."""
281
402
  with self._conn() as conn:
@@ -0,0 +1,72 @@
1
+ """
2
+ test_cairn_client.py — Unit tests for cairn federation init.
3
+ """
4
+ from unittest.mock import patch, MagicMock
5
+ import pytest
6
+
7
+ from sentinel.cairn_client import init_project_root, index_repo
8
+ from sentinel.config_loader import RepoConfig
9
+
10
+
11
+ def _fake_completed(rc=0, stdout="", stderr=""):
12
+ m = MagicMock()
13
+ m.returncode = rc
14
+ m.stdout = stdout
15
+ m.stderr = stderr
16
+ return m
17
+
18
+
19
+ # ── init_project_root ─────────────────────────────────────────────────────────
20
+
21
+ def test_init_project_root_skips_when_marker_exists(tmp_path):
22
+ (tmp_path / ".cairn").mkdir()
23
+ (tmp_path / ".cairn" / ".cairn-project").touch()
24
+ with patch("sentinel.cairn_client.subprocess.run") as run:
25
+ ok = init_project_root(str(tmp_path))
26
+ assert ok is True
27
+ run.assert_not_called()
28
+
29
+
30
+ def test_init_project_root_runs_cairn_install_when_missing(tmp_path):
31
+ with patch("sentinel.cairn_client.subprocess.run",
32
+ return_value=_fake_completed(rc=0)) as run:
33
+ ok = init_project_root(str(tmp_path))
34
+ assert ok is True
35
+ run.assert_called_once()
36
+ args, kwargs = run.call_args
37
+ assert args[0] == ["cairn", "install"]
38
+ assert kwargs["cwd"] == str(tmp_path)
39
+
40
+
41
+ def test_init_project_root_returns_false_on_install_failure(tmp_path):
42
+ with patch("sentinel.cairn_client.subprocess.run",
43
+ return_value=_fake_completed(rc=1, stderr="boom")):
44
+ ok = init_project_root(str(tmp_path))
45
+ assert ok is False
46
+
47
+
48
+ def test_init_project_root_returns_false_on_subprocess_exception(tmp_path):
49
+ with patch("sentinel.cairn_client.subprocess.run",
50
+ side_effect=FileNotFoundError("cairn not on PATH")):
51
+ ok = init_project_root(str(tmp_path))
52
+ assert ok is False
53
+
54
+
55
+ def test_init_project_root_creates_nothing_when_dir_missing(tmp_path):
56
+ missing = tmp_path / "does-not-exist"
57
+ with patch("sentinel.cairn_client.subprocess.run") as run:
58
+ ok = init_project_root(str(missing))
59
+ assert ok is False
60
+ run.assert_not_called()
61
+
62
+
63
+ # ── index_repo (existing behaviour — guard against regression) ────────────────
64
+
65
+ def test_index_repo_skips_when_marker_exists(tmp_path):
66
+ (tmp_path / ".cairn").mkdir()
67
+ (tmp_path / ".cairn" / ".cairn-project").touch()
68
+ repo = RepoConfig(repo_name="r", local_path=str(tmp_path), branch="main")
69
+ with patch("sentinel.cairn_client.subprocess.run") as run:
70
+ ok = index_repo(repo)
71
+ assert ok is True
72
+ run.assert_not_called()
@@ -0,0 +1,53 @@
1
+ """
2
+ test_fix_engine_cmd.py — Tests for _claude_cmd flag composition.
3
+
4
+ The OAuth path silently fast-failed in production until 1.6.1 because
5
+ _claude_cmd unconditionally passed --bare, which forces API-key-only auth
6
+ and rejects the OAuth attempt's keyless env. These tests pin the new
7
+ behaviour: --bare only when the API key is actually in the env.
8
+ """
9
+ from sentinel.fix_engine import _claude_cmd
10
+
11
+
12
+ def test_default_includes_bare_for_backcompat():
13
+ """Existing callers without an explicit flag still get --bare."""
14
+ cmd = _claude_cmd("claude", "hi")
15
+ assert "--bare" in cmd
16
+
17
+
18
+ def test_use_bare_false_drops_bare():
19
+ cmd = _claude_cmd("claude", "hi", use_bare=False)
20
+ assert "--bare" not in cmd
21
+
22
+
23
+ def test_use_bare_true_keeps_bare():
24
+ cmd = _claude_cmd("claude", "hi", use_bare=True)
25
+ assert "--bare" in cmd
26
+
27
+
28
+ def test_session_id_adds_resume():
29
+ cmd = _claude_cmd("claude", "hi", session_id="abc-123")
30
+ assert "--resume" in cmd
31
+ i = cmd.index("--resume")
32
+ assert cmd[i + 1] == "abc-123"
33
+
34
+
35
+ def test_no_session_id_skips_resume():
36
+ cmd = _claude_cmd("claude", "hi", session_id="")
37
+ assert "--resume" not in cmd
38
+
39
+
40
+ def test_output_format_json_always_present():
41
+ cmd_a = _claude_cmd("claude", "hi", use_bare=True)
42
+ cmd_b = _claude_cmd("claude", "hi", use_bare=False)
43
+ for cmd in (cmd_a, cmd_b):
44
+ assert "--output-format" in cmd
45
+ i = cmd.index("--output-format")
46
+ assert cmd[i + 1] == "json"
47
+
48
+
49
+ def test_print_and_prompt_are_last():
50
+ """`--print <prompt>` must come last so all flags are interpreted."""
51
+ cmd = _claude_cmd("claude", "the actual prompt", use_bare=False)
52
+ assert cmd[-2] == "--print"
53
+ assert cmd[-1] == "the actual prompt"