@misterhuydo/sentinel 1.6.13 → 1.6.15
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 +1 -1
- package/.cairn/session.json +2 -2
- package/lib/generate.js +15 -3
- package/package.json +1 -1
- package/python/sentinel/__init__.py +1 -1
- package/python/sentinel/__pycache__/config_loader.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/slack_bot.cpython-311.pyc +0 -0
- package/python/sentinel/config_loader.py +2 -0
- package/python/sentinel/main.py +42 -6
- package/python/sentinel/slack_bot.py +41 -0
- package/python/tests/test_bot_noise_filter.py +51 -0
- package/python/tests/test_config_loader.py +15 -0
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-04-
|
|
1
|
+
2026-04-28T09:31:55.053Z
|
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-04-
|
|
3
|
-
"checkpoint_at": "2026-04-
|
|
2
|
+
"message": "Auto-checkpoint at 2026-04-28T09:57:09.547Z",
|
|
3
|
+
"checkpoint_at": "2026-04-28T09:57:09.549Z",
|
|
4
4
|
"active_files": [
|
|
5
5
|
"J:\\Projects\\Sentinel\\cli\\bin\\sentinel.js",
|
|
6
6
|
"J:\\Projects\\Sentinel\\cli\\lib\\test.js"
|
package/lib/generate.js
CHANGED
|
@@ -45,8 +45,15 @@ if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
|
|
45
45
|
exit 0
|
|
46
46
|
fi
|
|
47
47
|
|
|
48
|
-
# Kill any orphaned sentinel processes
|
|
49
|
-
|
|
48
|
+
# Kill any orphaned sentinel.main processes whose cwd is this project dir.
|
|
49
|
+
# Match by /proc/PID/cwd (immune to relative-vs-absolute --config arg) so we
|
|
50
|
+
# don't leak duplicate workers when watchdog spawns over a still-alive worker.
|
|
51
|
+
for _pid in $(pgrep -f 'sentinel\\.main' 2>/dev/null); do
|
|
52
|
+
_pcwd=$(readlink -f "/proc/$_pid/cwd" 2>/dev/null) || continue
|
|
53
|
+
if [[ "$_pcwd" == "$DIR" ]]; then
|
|
54
|
+
kill "$_pid" 2>/dev/null && echo "[sentinel] killed orphaned sentinel-main PID $_pid (cwd=$DIR)"
|
|
55
|
+
fi
|
|
56
|
+
done
|
|
50
57
|
rm -f "$PID_FILE"
|
|
51
58
|
|
|
52
59
|
WORKSPACE="$(dirname "$DIR")"
|
|
@@ -200,7 +207,12 @@ if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
|
|
200
207
|
echo "[sentinel] __NAME__ already running (PID $(cat "$PID_FILE"))"
|
|
201
208
|
exit 0
|
|
202
209
|
fi
|
|
203
|
-
|
|
210
|
+
for _pid in $(pgrep -f 'sentinel\.main' 2>/dev/null); do
|
|
211
|
+
_pcwd=$(readlink -f "/proc/$_pid/cwd" 2>/dev/null) || continue
|
|
212
|
+
if [[ "$_pcwd" == "$DIR" ]]; then
|
|
213
|
+
kill "$_pid" 2>/dev/null && echo "[sentinel] killed orphaned sentinel-main PID $_pid (cwd=$DIR)"
|
|
214
|
+
fi
|
|
215
|
+
done
|
|
204
216
|
rm -f "$PID_FILE"
|
|
205
217
|
WORKSPACE="$(dirname "$DIR")"
|
|
206
218
|
_claude_pro=true
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.6.
|
|
1
|
+
__version__ = "1.6.15"
|
|
Binary file
|
|
Binary file
|
|
@@ -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):
|
package/python/sentinel/main.py
CHANGED
|
@@ -61,6 +61,20 @@ def _project_lock(project_name: str) -> "asyncio.Lock":
|
|
|
61
61
|
return _project_locks[project_name]
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
def _restart_via_execv() -> None:
|
|
65
|
+
"""Re-exec the current process, preserving the original `python -m sentinel.main` invocation.
|
|
66
|
+
|
|
67
|
+
sys.argv[0] under -m mode is the absolute path to main.py — running that path
|
|
68
|
+
directly makes Python treat the file as a script, which breaks the relative
|
|
69
|
+
`from .cairn_client import ...` imports. Detect -m mode and re-launch with
|
|
70
|
+
`[python, '-m', 'sentinel.main', *original_args]` instead.
|
|
71
|
+
"""
|
|
72
|
+
if __package__ and sys.argv and sys.argv[0].endswith(("main.py", "main")):
|
|
73
|
+
os.execv(sys.executable, [sys.executable, "-m", f"{__package__}.main", *sys.argv[1:]])
|
|
74
|
+
else:
|
|
75
|
+
os.execv(sys.executable, [sys.executable, *sys.argv])
|
|
76
|
+
|
|
77
|
+
|
|
64
78
|
def _on_sigusr1(*_):
|
|
65
79
|
global _report_requested
|
|
66
80
|
_report_requested = True
|
|
@@ -619,6 +633,19 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
619
633
|
async def _handle_issue_locked(event, repo, cfg_loader, store):
|
|
620
634
|
"""Heavy work portion of _handle_issue — serialised per project via _project_lock."""
|
|
621
635
|
sentinel = cfg_loader.sentinel
|
|
636
|
+
|
|
637
|
+
# Re-check dedupe AFTER acquiring the lock — the pre-lock check in
|
|
638
|
+
# _handle_issue is racy when two poll cycles (or two worker processes
|
|
639
|
+
# sharing the same sentinel.db) both see the issue file before either has
|
|
640
|
+
# recorded an attempt. The lock then merely serialises duplicate work.
|
|
641
|
+
if store.fix_attempted_recently(event.fingerprint, hours=24):
|
|
642
|
+
logger.info(
|
|
643
|
+
"Issue %s skipped (post-lock recheck) — fingerprint %s already attempted",
|
|
644
|
+
event.source, event.fingerprint,
|
|
645
|
+
)
|
|
646
|
+
mark_done(event.issue_file)
|
|
647
|
+
return None
|
|
648
|
+
|
|
622
649
|
auto_commit = resolve_auto_commit(repo, sentinel)
|
|
623
650
|
auto_release = resolve_auto_release(repo, sentinel)
|
|
624
651
|
|
|
@@ -945,7 +972,17 @@ async def _handle_issue_locked(event, repo, cfg_loader, store):
|
|
|
945
972
|
|
|
946
973
|
except Exception:
|
|
947
974
|
logger.exception("Unexpected error processing issue %s — archiving to prevent retry loop", event.source)
|
|
948
|
-
|
|
975
|
+
# Don't insert a 'failed' row if the apply/publish flow already recorded
|
|
976
|
+
# an outcome (success or otherwise) for this fingerprint — otherwise a
|
|
977
|
+
# post-success exception (e.g. mark_done TOCTOU) would overwrite the
|
|
978
|
+
# success in Boss DM aggregation that picks the most recent row.
|
|
979
|
+
if not store.fix_attempted_recently(event.fingerprint, hours=24):
|
|
980
|
+
store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
|
|
981
|
+
else:
|
|
982
|
+
logger.info(
|
|
983
|
+
"Catch-all skipped recording 'failed' for %s — fixes table already has a recent row for this fingerprint",
|
|
984
|
+
event.fingerprint,
|
|
985
|
+
)
|
|
949
986
|
mark_done(event.issue_file)
|
|
950
987
|
return {"submitter": getattr(event, "submitter_user_id", ""),
|
|
951
988
|
"repo_name": repo.repo_name if repo else event.target_repo,
|
|
@@ -1481,7 +1518,7 @@ def _check_and_upgrade(cfg: SentinelConfig) -> bool:
|
|
|
1481
1518
|
except Exception:
|
|
1482
1519
|
pass
|
|
1483
1520
|
|
|
1484
|
-
|
|
1521
|
+
_restart_via_execv()
|
|
1485
1522
|
return True # unreachable after execv
|
|
1486
1523
|
|
|
1487
1524
|
|
|
@@ -1603,7 +1640,7 @@ async def _handle_dev_task(task, cfg_loader: ConfigLoader, store: StateStore):
|
|
|
1603
1640
|
f"{mentions}:white_check_mark: *Patch finished* — running tests before restart...",
|
|
1604
1641
|
)
|
|
1605
1642
|
# Run test suite before restarting — revert if tests fail.
|
|
1606
|
-
import
|
|
1643
|
+
import subprocess as _sp
|
|
1607
1644
|
_loop2 = asyncio.get_event_loop()
|
|
1608
1645
|
_code_dir = Path(sentinel.sentinel_dev_repo_path or ".")
|
|
1609
1646
|
_venv_pytest = _code_dir / ".venv" / "bin" / "pytest"
|
|
@@ -1666,7 +1703,7 @@ async def _handle_dev_task(task, cfg_loader: ConfigLoader, store: StateStore):
|
|
|
1666
1703
|
f"SOAK_MINUTES={_soak_mins}\n"
|
|
1667
1704
|
)
|
|
1668
1705
|
await asyncio.sleep(1) # let the Slack message flush
|
|
1669
|
-
|
|
1706
|
+
_restart_via_execv()
|
|
1670
1707
|
elif status == "needs_human":
|
|
1671
1708
|
# Boss qualifies the raw Patch explanation before surfacing to users
|
|
1672
1709
|
qualified = _boss_qualify_dev_reason(detail, sentinel)
|
|
@@ -2006,9 +2043,8 @@ async def _patch_soak_monitor(cfg_loader: ConfigLoader) -> None:
|
|
|
2006
2043
|
f":x: *Patch soak failed* — new errors detected after patch `{patch_hash[:8]}`. "
|
|
2007
2044
|
f"Reverted {'✓' if revert_ok else '(failed — check manually)'}. Restarting...")
|
|
2008
2045
|
if revert_ok:
|
|
2009
|
-
import os as _os, sys as _sys
|
|
2010
2046
|
await asyncio.sleep(2)
|
|
2011
|
-
|
|
2047
|
+
_restart_via_execv()
|
|
2012
2048
|
return
|
|
2013
2049
|
|
|
2014
2050
|
# Clean soak — notify
|
|
@@ -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
|