@misterhuydo/sentinel 1.6.12 → 1.6.14

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.
package/.cairn/.hint-lock CHANGED
@@ -1 +1 @@
1
- 2026-04-27T13:09:20.340Z
1
+ 2026-04-28T09:31:55.053Z
@@ -1,6 +1,6 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-04-27T13:29:19.813Z",
3
- "checkpoint_at": "2026-04-27T13:29:19.814Z",
2
+ "message": "Auto-checkpoint at 2026-04-28T09:29:24.499Z",
3
+ "checkpoint_at": "2026-04-28T09:29:24.501Z",
4
4
  "active_files": [
5
5
  "J:\\Projects\\Sentinel\\cli\\bin\\sentinel.js",
6
6
  "J:\\Projects\\Sentinel\\cli\\lib\\test.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.6.12",
3
+ "version": "1.6.14",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -1 +1 @@
1
- __version__ = "1.6.12"
1
+ __version__ = "1.6.14"
@@ -85,6 +85,7 @@ class SentinelConfig:
85
85
  auto_commit: bool = False # project-level default: push directly to main (no PR); repos can override
86
86
  auto_release: bool = False # project-level default: trigger CI/CD pipeline after push; repos can override
87
87
  auto_raise_issues: bool = False # if True, auto-fix detected log errors without waiting for human to raise an issue
88
+ bot_watcher_noise_pattern: str = "" # regex; matching bot-alert text is dropped before queueing as an issue
88
89
 
89
90
 
90
91
  @dataclass
@@ -284,6 +285,7 @@ class ConfigLoader:
284
285
  c.auto_release = d["AUTO_RELEASE"].lower() == "true"
285
286
  if "AUTO_RAISE_ISSUES" in d:
286
287
  c.auto_raise_issues = d["AUTO_RAISE_ISSUES"].lower() == "true"
288
+ c.bot_watcher_noise_pattern = d.get("BOT_WATCHER_NOISE_PATTERN", "").strip()
287
289
  self.sentinel = c
288
290
 
289
291
  def _load_log_sources(self):
@@ -477,7 +477,7 @@ def apply_and_commit(
477
477
  logger.error("git pull failed for %s:\n%s", repo.repo_name, r.stderr)
478
478
  return "failed", ""
479
479
 
480
- r = _git(["apply", "--check", "--recount", "--ignore-whitespace", str(patch_path)], cwd=local_path, env=env)
480
+ r = _git(["apply", "--check", "--recount", "--ignore-whitespace", "-C1", str(patch_path)], cwd=local_path, env=env)
481
481
  if r.returncode != 0:
482
482
  try:
483
483
  original = patch_path.read_text(encoding="utf-8", errors="replace")
@@ -487,12 +487,12 @@ def apply_and_commit(
487
487
  repaired = None
488
488
  if repaired and repaired != original:
489
489
  patch_path.write_text(repaired, encoding="utf-8")
490
- r = _git(["apply", "--check", "--recount", "--ignore-whitespace", str(patch_path)], cwd=local_path, env=env)
490
+ r = _git(["apply", "--check", "--recount", "--ignore-whitespace", "-C1", str(patch_path)], cwd=local_path, env=env)
491
491
  if r.returncode != 0:
492
492
  logger.error("Patch dry-run failed for %s:\n%s", event.fingerprint, r.stderr)
493
493
  return "failed", ""
494
494
 
495
- r = _git(["apply", "--recount", "--ignore-whitespace", str(patch_path)], cwd=local_path, env=env)
495
+ r = _git(["apply", "--recount", "--ignore-whitespace", "-C1", str(patch_path)], cwd=local_path, env=env)
496
496
  if r.returncode != 0:
497
497
  logger.error("git apply failed for %s:\n%s", event.fingerprint, r.stderr)
498
498
  return "failed", ""
@@ -601,7 +601,7 @@ def apply_and_commit_multi(
601
601
  dry_run_failures.append(f"{name}: git pull failed: {r.stderr.strip()[:200]}")
602
602
  continue
603
603
  # Dry-run
604
- r = _git(["apply", "--check", "--recount", "--ignore-whitespace", str(sub_path)],
604
+ r = _git(["apply", "--check", "--recount", "--ignore-whitespace", "-C1", str(sub_path)],
605
605
  cwd=repo.local_path, env=env)
606
606
  if r.returncode != 0:
607
607
  # Try repairing the patch by splicing in any intermediate file lines
@@ -616,7 +616,7 @@ def apply_and_commit_multi(
616
616
  if repaired and repaired != original:
617
617
  sub_path.write_text(repaired, encoding="utf-8")
618
618
  r = _git(
619
- ["apply", "--check", "--recount", "--ignore-whitespace", str(sub_path)],
619
+ ["apply", "--check", "--recount", "--ignore-whitespace", "-C1", str(sub_path)],
620
620
  cwd=repo.local_path, env=env,
621
621
  )
622
622
  if r.returncode == 0:
@@ -653,7 +653,7 @@ def apply_and_commit_multi(
653
653
  "reason": "", "sub_patch_path": sub_path,
654
654
  }
655
655
 
656
- r = _git(["apply", "--recount", "--ignore-whitespace", str(sub_path)],
656
+ r = _git(["apply", "--recount", "--ignore-whitespace", "-C1", str(sub_path)],
657
657
  cwd=repo.local_path, env=env)
658
658
  if r.returncode != 0:
659
659
  entry["reason"] = f"apply failed: {r.stderr.strip()[:200]}"
@@ -563,7 +563,11 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
563
563
  return
564
564
 
565
565
  if store.fix_attempted_recently(event.fingerprint, hours=24):
566
- logger.debug("Issue already processed recently: %s", event.source)
566
+ logger.info(
567
+ "Issue %s skipped — fingerprint %s attempted in last 24h "
568
+ "(use Boss `retry_issue` to clear the prior row and re-attempt)",
569
+ event.source, event.fingerprint,
570
+ )
567
571
  mark_done(event.issue_file)
568
572
  return
569
573
 
@@ -2632,6 +2632,27 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
2632
2632
  except Exception as _e:
2633
2633
  logger.debug("retry_issue: state_store guard failed (non-fatal): %s", _e)
2634
2634
 
2635
+ # Clear the 24h dedupe so _handle_issue actually re-runs the fix.
2636
+ # fix_attempted_recently() ignores status='skipped', so flipping any
2637
+ # recent 'failed' row for this fingerprint is enough.
2638
+ try:
2639
+ with store._conn() as _c:
2640
+ _n = _c.execute(
2641
+ "UPDATE fixes SET status='skipped' "
2642
+ "WHERE fingerprint=? AND status='failed' "
2643
+ "AND timestamp >= datetime('now', '-24 hours')",
2644
+ (_fp,),
2645
+ ).rowcount
2646
+ _c.commit()
2647
+ if _n:
2648
+ logger.info(
2649
+ "Boss retry_issue: cleared %d prior failed row(s) for fingerprint %s "
2650
+ "so the retry won't be deduped",
2651
+ _n, _fp,
2652
+ )
2653
+ except Exception as _e:
2654
+ logger.debug("retry_issue: clearing prior failed rows failed (non-fatal): %s", _e)
2655
+
2635
2656
  # Re-submit as a fresh issue file
2636
2657
  issues_dir = project_dir / "issues"
2637
2658
  issues_dir.mkdir(exist_ok=True)
@@ -55,6 +55,30 @@ class _Session:
55
55
  _sessions: dict[str, _Session] = {}
56
56
  _sessions_lock = asyncio.Lock()
57
57
 
58
+ # Cached compiled regex for bot-watcher noise filter — recompiled when the
59
+ # config value changes (so SIGHUP reload picks up edits without a restart).
60
+ _noise_filter_cache: tuple[str, "object | None"] = ("", None)
61
+
62
+
63
+ def _get_noise_filter(pattern: str):
64
+ """Return a compiled re.Pattern for the noise filter, or None if disabled/invalid."""
65
+ global _noise_filter_cache
66
+ cached_pattern, cached_re = _noise_filter_cache
67
+ if pattern == cached_pattern:
68
+ return cached_re
69
+ if not pattern:
70
+ _noise_filter_cache = ("", None)
71
+ return None
72
+ import re as _re
73
+ try:
74
+ compiled = _re.compile(pattern, _re.IGNORECASE)
75
+ except _re.error as e:
76
+ logger.warning("BOT_WATCHER_NOISE_PATTERN is not a valid regex (%s) — filter disabled", e)
77
+ _noise_filter_cache = (pattern, None)
78
+ return None
79
+ _noise_filter_cache = (pattern, compiled)
80
+ return compiled
81
+
58
82
 
59
83
  async def _get_or_create_session(user_id: str, user_name: str, channel: str) -> _Session:
60
84
  async with _sessions_lock:
@@ -272,6 +296,23 @@ async def _handle_bot_message(event: dict, client, cfg_loader, store) -> None:
272
296
  if not text:
273
297
  return
274
298
 
299
+ # Drop operational-noise alerts (e.g. wrong-PIN attempts, single transient
300
+ # SMS failures) before they enter the issues queue. Configurable via
301
+ # BOT_WATCHER_NOISE_PATTERN in sentinel.properties (regex, alternatives
302
+ # joined with `|`). Empty = disabled.
303
+ noise_re = _get_noise_filter(getattr(cfg_loader.sentinel, "bot_watcher_noise_pattern", ""))
304
+ if noise_re is not None and noise_re.search(text):
305
+ logger.info(
306
+ "Bot watcher: dropping noise-pattern match from %s (preview: %r)",
307
+ bot_id, text[:120].replace("\n", " "),
308
+ )
309
+ if ts:
310
+ try:
311
+ await client.reactions_add(channel=channel, timestamp=ts, name="mute")
312
+ except Exception:
313
+ pass # reactions:write scope may not be granted — non-fatal
314
+ return
315
+
275
316
  # Find the project this bot is registered to
276
317
  # Match on either the B-prefixed bot_id or the U-prefixed user ID
277
318
  _user_id = event.get("user", "")
@@ -0,0 +1,51 @@
1
+ """Tests for the bot-watcher noise-pattern filter (slack_bot._get_noise_filter)."""
2
+
3
+ from sentinel import slack_bot
4
+
5
+
6
+ def _reset_cache():
7
+ slack_bot._noise_filter_cache = ("", None)
8
+
9
+
10
+ def test_empty_pattern_disables_filter():
11
+ _reset_cache()
12
+ assert slack_bot._get_noise_filter("") is None
13
+
14
+
15
+ def test_invalid_regex_disables_filter(caplog):
16
+ _reset_cache()
17
+ import logging
18
+ with caplog.at_level(logging.WARNING):
19
+ assert slack_bot._get_noise_filter("(unbalanced") is None
20
+ assert any("not a valid regex" in r.message for r in caplog.records)
21
+
22
+
23
+ def test_pattern_compiles_and_caches():
24
+ _reset_cache()
25
+ p1 = slack_bot._get_noise_filter("Illegal pin logon|SMS delivery FAILED")
26
+ assert p1 is not None
27
+ p2 = slack_bot._get_noise_filter("Illegal pin logon|SMS delivery FAILED")
28
+ assert p1 is p2 # cache hit returns the same compiled object
29
+
30
+
31
+ def test_pattern_change_recompiles():
32
+ _reset_cache()
33
+ p1 = slack_bot._get_noise_filter("foo")
34
+ p2 = slack_bot._get_noise_filter("bar")
35
+ assert p1 is not p2
36
+
37
+
38
+ def test_match_is_case_insensitive():
39
+ _reset_cache()
40
+ pat = slack_bot._get_noise_filter("Illegal pin logon")
41
+ assert pat.search("ILLEGAL PIN LOGON ATTEMPTED")
42
+ assert pat.search("user typed wrong PIN — Illegal Pin Logon attempted")
43
+ assert not pat.search("normal startup message")
44
+
45
+
46
+ def test_alternatives_match_either():
47
+ _reset_cache()
48
+ pat = slack_bot._get_noise_filter("Illegal pin logon|1 SMS delivery FAILED")
49
+ assert pat.search("Illegal pin logon attempted.")
50
+ assert pat.search(":no_entry: 1 SMS delivery FAILED in the last 5 minutes")
51
+ assert not pat.search("MsGraphMailSender threw exception")
@@ -74,6 +74,21 @@ def test_sentinel_config(config_dir):
74
74
  assert cfg.github_token == "ghp_test"
75
75
 
76
76
 
77
+ def test_bot_watcher_noise_pattern_default_empty(config_dir):
78
+ cfg = ConfigLoader(str(config_dir)).sentinel
79
+ assert cfg.bot_watcher_noise_pattern == ""
80
+
81
+
82
+ def test_bot_watcher_noise_pattern_loaded(tmp_path):
83
+ (tmp_path / "log-configs").mkdir()
84
+ (tmp_path / "repo-configs").mkdir()
85
+ (tmp_path / "sentinel.properties").write_text(
86
+ "BOT_WATCHER_NOISE_PATTERN=Illegal pin logon|1 SMS delivery FAILED\n"
87
+ )
88
+ cfg = ConfigLoader(str(tmp_path)).sentinel
89
+ assert cfg.bot_watcher_noise_pattern == "Illegal pin logon|1 SMS delivery FAILED"
90
+
91
+
77
92
  def test_log_sources(config_dir):
78
93
  loader = ConfigLoader(str(config_dir))
79
94
  assert "elprint-salescore" in loader.log_sources