@misterhuydo/sentinel 1.6.8 → 1.6.10

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-24T14:02:31.712Z
1
+ 2026-04-25T09:41:31.525Z
@@ -10,11 +10,5 @@
10
10
  "state": "edit-ready",
11
11
  "minifiedAt": 1774252437350.0059,
12
12
  "readCount": 1
13
- },
14
- "J:\\Projects\\Sentinel\\cli\\python\\sentinel\\cicd_trigger.py": {
15
- "tempPath": "J:\\Projects\\Sentinel\\cli\\.cairn\\views\\7802b9_cicd_trigger.py",
16
- "state": "compressed",
17
- "minifiedAt": 1774523631399.9514,
18
- "readCount": 1
19
13
  }
20
14
  }
@@ -1,10 +1,9 @@
1
1
  {
2
- "message": "Auto-checkpoint at 2026-04-24T13:58:01.367Z",
3
- "checkpoint_at": "2026-04-24T13:58:01.369Z",
2
+ "message": "Auto-checkpoint at 2026-04-25T09:42:20.720Z",
3
+ "checkpoint_at": "2026-04-25T09:42:20.724Z",
4
4
  "active_files": [
5
5
  "J:\\Projects\\Sentinel\\cli\\bin\\sentinel.js",
6
- "J:\\Projects\\Sentinel\\cli\\lib\\test.js",
7
- "J:\\Projects\\Sentinel\\cli\\python\\sentinel\\cicd_trigger.py"
6
+ "J:\\Projects\\Sentinel\\cli\\lib\\test.js"
8
7
  ],
9
8
  "notes": [
10
9
  {
@@ -186,7 +185,6 @@
186
185
  ],
187
186
  "mtime_snapshot": {
188
187
  "J:\\Projects\\Sentinel\\cli\\bin\\sentinel.js": 1774252515044.4768,
189
- "J:\\Projects\\Sentinel\\cli\\lib\\test.js": 1774252437350.0059,
190
- "J:\\Projects\\Sentinel\\cli\\python\\sentinel\\cicd_trigger.py": 1774523631399.9514
188
+ "J:\\Projects\\Sentinel\\cli\\lib\\test.js": 1774252437350.0059
191
189
  }
192
190
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.6.8",
3
+ "version": "1.6.10",
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.8"
1
+ __version__ = "1.6.10"
@@ -582,6 +582,12 @@ def generate_fix(
582
582
  if timed_out:
583
583
  logger.error("Claude Code timed out for %s", event.fingerprint)
584
584
  return "error", None, ""
585
+ # Envelope check first: a successful JSON result means the attempt
586
+ # authenticated cleanly, even if the diff body contains substrings
587
+ # like "HttpStatus.UNAUTHORIZED" that would fool the substring scan.
588
+ parsed_attempt = _parse_claude_json(raw_output)
589
+ if not parsed_attempt["is_error"] and parsed_attempt["result"]:
590
+ break
585
591
  if not _is_auth_error(raw_output):
586
592
  break
587
593
  logger.warning("fix_engine: %s auth error for %s — trying next method", label, event.fingerprint)
@@ -605,15 +611,18 @@ def generate_fix(
605
611
  slack_alert(cfg.slack_bot_token, cfg.slack_channel, msg)
606
612
  return "error", None, ""
607
613
 
614
+ # Parse the JSON envelope: get session_id, cost, and the actual result text.
615
+ parsed = _parse_claude_json(raw_output)
616
+
617
+ # Pass empty when not is_error — the auth-signal regex would otherwise
618
+ # match trigger words ("authentication", "login", "billing", ...) inside
619
+ # successful diff content.
608
620
  alert_if_rate_limited(
609
621
  cfg.slack_bot_token,
610
622
  cfg.slack_channel,
611
623
  source=f"fix_engine/{event.fingerprint}",
612
- output=raw_output,
624
+ output=raw_output if parsed["is_error"] else "",
613
625
  )
614
-
615
- # Parse the JSON envelope: get session_id, cost, and the actual result text.
616
- parsed = _parse_claude_json(raw_output)
617
626
  if parsed["is_error"] and not parsed["result"]:
618
627
  # Fall back to legacy text parsing if JSON decode failed completely.
619
628
  # (Older Claude CLI versions may not emit JSON; --output-format flag is ignored.)
@@ -479,7 +479,7 @@ def _run_tests(repo: RepoConfig, local_path: str) -> bool:
479
479
 
480
480
  logger.info("Running tests for %s: %s", repo.repo_name, " ".join(cmd))
481
481
  try:
482
- r = subprocess.run(cmd, cwd=local_path, capture_output=True, text=True, timeout=300)
482
+ r = subprocess.run(cmd, cwd=local_path, capture_output=True, text=True, timeout=900)
483
483
  except FileNotFoundError:
484
484
  raise MissingToolError(cmd[0])
485
485
  if r.returncode != 0:
@@ -606,6 +606,15 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
606
606
  mark_done(event.issue_file) # archive so it doesn't re-prompt every poll
607
607
  return
608
608
 
609
+ # Per-project lock — serialise issue processing within a project so two
610
+ # concurrent claude sessions never race on the working tree of any repo.
611
+ async with _project_lock(sentinel.project_name or "_default"):
612
+ return await _handle_issue_locked(event, repo, cfg_loader, store)
613
+
614
+
615
+ async def _handle_issue_locked(event, repo, cfg_loader, store):
616
+ """Heavy work portion of _handle_issue — serialised per project via _project_lock."""
617
+ sentinel = cfg_loader.sentinel
609
618
  auto_commit = resolve_auto_commit(repo, sentinel)
610
619
  auto_release = resolve_auto_release(repo, sentinel)
611
620
 
@@ -1,95 +1,126 @@
1
- """
2
- test_fix_engine_json.py — Unit tests for _parse_claude_json().
3
-
4
- Parses the single-object JSON emitted by `claude --print --output-format json`.
5
- Critical: must extract session_id (for resume), cost (for budget tracking),
6
- and the result text (which contains the patch).
7
- """
8
- import json
9
- import pytest
10
-
11
- from sentinel.fix_engine import _parse_claude_json
12
-
13
-
14
- def _wrap(result_text: str, session_id: str = "abc-123",
15
- cost: float = 0.05, is_error: bool = False) -> str:
16
- return json.dumps({
17
- "type": "result",
18
- "subtype": "success" if not is_error else "error",
19
- "is_error": is_error,
20
- "result": result_text,
21
- "session_id": session_id,
22
- "total_cost_usd": cost,
23
- "duration_ms": 1234,
24
- "stop_reason": "end_turn",
25
- })
26
-
27
-
28
- # ── happy path ────────────────────────────────────────────────────────────────
29
-
30
- def test_extracts_result_session_cost():
31
- raw = _wrap("Here is the patch:\n```diff\n...```", "sess-1", 0.07)
32
- parsed = _parse_claude_json(raw)
33
- assert parsed["session_id"] == "sess-1"
34
- assert parsed["total_cost_usd"] == pytest.approx(0.07)
35
- assert "patch" in parsed["result"]
36
- assert parsed["is_error"] is False
37
-
38
-
39
- def test_handles_zero_cost():
40
- raw = _wrap("ok", "sess", 0.0)
41
- parsed = _parse_claude_json(raw)
42
- assert parsed["total_cost_usd"] == 0.0
43
-
44
-
45
- def test_carries_is_error_flag():
46
- raw = _wrap("err msg", "sess", 0.01, is_error=True)
47
- parsed = _parse_claude_json(raw)
48
- assert parsed["is_error"] is True
49
-
50
-
51
- # ── tolerant inputs ───────────────────────────────────────────────────────────
52
-
53
- def test_strips_leading_trailing_whitespace():
54
- raw = " \n" + _wrap("ok", "s", 0.0) + "\n "
55
- parsed = _parse_claude_json(raw)
56
- assert parsed["session_id"] == "s"
57
- assert parsed["result"] == "ok"
58
-
59
-
60
- def test_finds_json_after_stderr_garbage():
61
- """Claude sometimes prints debug lines before the JSON object."""
62
- raw = (
63
- "Some stderr line\n"
64
- "Another warning\n"
65
- + _wrap("payload", "s", 0.0)
66
- )
67
- parsed = _parse_claude_json(raw)
68
- assert parsed["session_id"] == "s"
69
- assert parsed["result"] == "payload"
70
-
71
-
72
- def test_returns_empty_on_unparseable():
73
- parsed = _parse_claude_json("not json at all")
74
- assert parsed["session_id"] == ""
75
- assert parsed["result"] == ""
76
- assert parsed["total_cost_usd"] == 0.0
77
- # When unparseable, surface as an error so caller doesn't silently swallow it
78
- assert parsed["is_error"] is True
79
-
80
-
81
- def test_returns_empty_on_empty_string():
82
- parsed = _parse_claude_json("")
83
- assert parsed["session_id"] == ""
84
- assert parsed["result"] == ""
85
- assert parsed["is_error"] is True
86
-
87
-
88
- def test_handles_missing_fields_gracefully():
89
- """If claude omits some fields, extract what's there and default the rest."""
90
- raw = json.dumps({"type": "result", "result": "x", "session_id": "s"})
91
- parsed = _parse_claude_json(raw)
92
- assert parsed["session_id"] == "s"
93
- assert parsed["result"] == "x"
94
- assert parsed["total_cost_usd"] == 0.0
95
- assert parsed["is_error"] is False
1
+ """
2
+ test_fix_engine_json.py — Unit tests for _parse_claude_json().
3
+
4
+ Parses the single-object JSON emitted by `claude --print --output-format json`.
5
+ Critical: must extract session_id (for resume), cost (for budget tracking),
6
+ and the result text (which contains the patch).
7
+ """
8
+ import json
9
+ import pytest
10
+
11
+ from sentinel.fix_engine import _parse_claude_json, _is_auth_error
12
+
13
+
14
+ def _wrap(result_text: str, session_id: str = "abc-123",
15
+ cost: float = 0.05, is_error: bool = False) -> str:
16
+ return json.dumps({
17
+ "type": "result",
18
+ "subtype": "success" if not is_error else "error",
19
+ "is_error": is_error,
20
+ "result": result_text,
21
+ "session_id": session_id,
22
+ "total_cost_usd": cost,
23
+ "duration_ms": 1234,
24
+ "stop_reason": "end_turn",
25
+ })
26
+
27
+
28
+ # ── happy path ────────────────────────────────────────────────────────────────
29
+
30
+ def test_extracts_result_session_cost():
31
+ raw = _wrap("Here is the patch:\n```diff\n...```", "sess-1", 0.07)
32
+ parsed = _parse_claude_json(raw)
33
+ assert parsed["session_id"] == "sess-1"
34
+ assert parsed["total_cost_usd"] == pytest.approx(0.07)
35
+ assert "patch" in parsed["result"]
36
+ assert parsed["is_error"] is False
37
+
38
+
39
+ def test_handles_zero_cost():
40
+ raw = _wrap("ok", "sess", 0.0)
41
+ parsed = _parse_claude_json(raw)
42
+ assert parsed["total_cost_usd"] == 0.0
43
+
44
+
45
+ def test_carries_is_error_flag():
46
+ raw = _wrap("err msg", "sess", 0.01, is_error=True)
47
+ parsed = _parse_claude_json(raw)
48
+ assert parsed["is_error"] is True
49
+
50
+
51
+ # ── tolerant inputs ───────────────────────────────────────────────────────────
52
+
53
+ def test_strips_leading_trailing_whitespace():
54
+ raw = " \n" + _wrap("ok", "s", 0.0) + "\n "
55
+ parsed = _parse_claude_json(raw)
56
+ assert parsed["session_id"] == "s"
57
+ assert parsed["result"] == "ok"
58
+
59
+
60
+ def test_finds_json_after_stderr_garbage():
61
+ """Claude sometimes prints debug lines before the JSON object."""
62
+ raw = (
63
+ "Some stderr line\n"
64
+ "Another warning\n"
65
+ + _wrap("payload", "s", 0.0)
66
+ )
67
+ parsed = _parse_claude_json(raw)
68
+ assert parsed["session_id"] == "s"
69
+ assert parsed["result"] == "payload"
70
+
71
+
72
+ def test_returns_empty_on_unparseable():
73
+ parsed = _parse_claude_json("not json at all")
74
+ assert parsed["session_id"] == ""
75
+ assert parsed["result"] == ""
76
+ assert parsed["total_cost_usd"] == 0.0
77
+ # When unparseable, surface as an error so caller doesn't silently swallow it
78
+ assert parsed["is_error"] is True
79
+
80
+
81
+ def test_returns_empty_on_empty_string():
82
+ parsed = _parse_claude_json("")
83
+ assert parsed["session_id"] == ""
84
+ assert parsed["result"] == ""
85
+ assert parsed["is_error"] is True
86
+
87
+
88
+ def test_handles_missing_fields_gracefully():
89
+ """If claude omits some fields, extract what's there and default the rest."""
90
+ raw = json.dumps({"type": "result", "result": "x", "session_id": "s"})
91
+ parsed = _parse_claude_json(raw)
92
+ assert parsed["session_id"] == "s"
93
+ assert parsed["result"] == "x"
94
+ assert parsed["total_cost_usd"] == 0.0
95
+ assert parsed["is_error"] is False
96
+
97
+
98
+ def test_successful_envelope_overrides_unauthorized_substring():
99
+ # Regression: fp 6cb7a875 on 2026-04-27 - Claude generated a valid patch
100
+ # whose unchanged-context lines contained "HttpStatus.UNAUTHORIZED",
101
+ # tripping _is_auth_error and triggering a false "Both API key and OAuth
102
+ # failed" alert. The fix is to consult the JSON envelope first.
103
+ diff_with_unauthorized = (
104
+ "diff --git a/Foo.java b/Foo.java\n"
105
+ "@@ -10,3 +10,3 @@\n"
106
+ " return new ResponseEntity<>(err, HttpStatus.UNAUTHORIZED);\n"
107
+ )
108
+ raw = _wrap(diff_with_unauthorized, is_error=False)
109
+ assert _is_auth_error(raw) is True # substring match alone is fooled
110
+ parsed = _parse_claude_json(raw)
111
+ assert parsed["is_error"] is False # envelope is the source of truth
112
+ assert parsed["result"]
113
+
114
+
115
+ @pytest.mark.parametrize("hint", [
116
+ "Error: Not logged in. Please run claude login.",
117
+ "API key is not set",
118
+ "401 Unauthorized",
119
+ "authentication failed",
120
+ ])
121
+ def test_is_auth_error_matches_real_hints(hint):
122
+ assert _is_auth_error(hint) is True
123
+
124
+
125
+ def test_is_auth_error_skips_benign_text():
126
+ assert _is_auth_error("All good, here is the patch") is False
@@ -1,171 +0,0 @@
1
- import logging
2
- import re
3
- import time
4
- from pathlib import Path
5
- import requests
6
- from .config_loader import RepoConfig
7
- from .state_store import StateStore
8
- logger = logging.getLogger(__name__)
9
- JENKINS_RELEASE_TIMEOUT = 900
10
- JENKINS_POLL_INTERVAL = 20
11
- def trigger(repo: RepoConfig, store: StateStore, fingerprint: str, wait: bool = False) -> bool:
12
- if not repo.cicd_type or not repo.cicd_job_url:
13
- return True
14
- cicd_type = repo.cicd_type.lower()
15
- if cicd_type == "jenkins":
16
- return _trigger_jenkins(repo)
17
- elif cicd_type in ("jenkins_release", "jenkins-release"):
18
- return _trigger_jenkins_release(repo, wait=wait)
19
- elif cicd_type in ("github_actions", "github-actions"):
20
- return _trigger_github_actions(repo, fingerprint)
21
- else:
22
- logger.warning("Unknown CICD_TYPE '%s' for %s", repo.cicd_type, repo.repo_name)
23
- return False
24
- def _trigger_jenkins(repo: RepoConfig) -> bool:
25
- url = f"{repo.cicd_job_url.rstrip('/')}/build"
26
- resp = requests.post(url, auth=("sentinel", repo.cicd_token), timeout=15)
27
- success = resp.status_code in (200, 201, 204)
28
- if success:
29
- logger.info("Jenkins build triggered: %s", repo.cicd_job_url)
30
- else:
31
- logger.error("Jenkins trigger failed (%s): %s", resp.status_code, resp.text[:200])
32
- return success
33
- def _get_last_build_number(session: requests.Session, job_url: str) -> int:
34
- try:
35
- r = session.get(f"{job_url.rstrip('/')}/api/json", timeout=10)
36
- data = r.json()
37
- lb = data.get("lastBuild")
38
- return lb["number"] if lb else 0
39
- except Exception as exc:
40
- logger.warning("Could not get last build number for %s: %s", job_url, exc)
41
- return 0
42
- def _wait_for_jenkins_build(
43
- session: requests.Session,
44
- job_url: str,
45
- prev_build_num: int,
46
- timeout: int = JENKINS_RELEASE_TIMEOUT,
47
- ) -> bool:
48
- deadline = time.time() + timeout
49
- build_num = None
50
- while time.time() < deadline:
51
- current = _get_last_build_number(session, job_url)
52
- if current > prev_build_num:
53
- build_num = current
54
- logger.info("Jenkins build
55
- break
56
- logger.debug("Waiting for Jenkins build to start (last=%d)...", current)
57
- time.sleep(JENKINS_POLL_INTERVAL)
58
- else:
59
- logger.error("Timed out waiting for Jenkins build to start: %s", job_url)
60
- return False
61
- build_api = f"{job_url.rstrip('/')}/{build_num}/api/json"
62
- while time.time() < deadline:
63
- try:
64
- r = session.get(build_api, timeout=10)
65
- data = r.json()
66
- if not data.get("building", True):
67
- result = data.get("result", "UNKNOWN")
68
- if result == "SUCCESS":
69
- logger.info("Jenkins build
70
- return True
71
- else:
72
- logger.error("Jenkins build
73
- return False
74
- elapsed = int(time.time() - (deadline - timeout))
75
- logger.info("Jenkins build
76
- except Exception as exc:
77
- logger.warning("Jenkins poll error for %s: %s", build_api, exc)
78
- time.sleep(JENKINS_POLL_INTERVAL)
79
- logger.error("Timed out waiting for Jenkins build
80
- return False
81
- def _trigger_jenkins_release(repo: RepoConfig, wait: bool = False) -> bool:
82
- release_ver, dev_ver = _maven_release_versions(repo.local_path)
83
- if not release_ver:
84
- logger.error("Jenkins release: could not determine release version from pom.xml")
85
- return False
86
- url = f"{repo.cicd_job_url.rstrip('/')}/m2release/submit"
87
- auth = (repo.cicd_user or "sentinel", repo.cicd_token)
88
- session = requests.Session()
89
- session.auth = auth
90
- from urllib.parse import urlparse
91
- parsed = urlparse(repo.cicd_job_url)
92
- jenkins_root = f"{parsed.scheme}://{parsed.netloc}"
93
- crumb_data = session.get(f"{jenkins_root}/crumbIssuer/api/json", timeout=10).json()
94
- crumb_header = {crumb_data["crumbRequestField"]: crumb_data["crumb"]}
95
- prev_build_num = _get_last_build_number(session, repo.cicd_job_url) if wait else 0
96
- scm_tag = f"{repo.repo_name}-{release_ver}"
97
- import json as _json
98
- stapler_json = _json.dumps({
99
- "releaseVersion": release_ver,
100
- "developmentVersion": dev_ver,
101
- "isDryRun": False,
102
- "specifyScmCredentials": False,
103
- "scmUsername": "",
104
- "scmCommentPrefix": "[maven-release-plugin] ",
105
- "appendHudsonUserName": False,
106
- "specifyScmTag": False,
107
- "scmTag": scm_tag,
108
- })
109
- resp = session.post(
110
- url,
111
- headers={
112
- **crumb_header,
113
- "Referer": f"{repo.cicd_job_url.rstrip('/')}/m2release/",
114
- },
115
- data={
116
- "releaseVersion": release_ver,
117
- "developmentVersion": dev_ver,
118
- "scmCommentPrefix": "[maven-release-plugin] ",
119
- "scmTag": scm_tag,
120
- "json": stapler_json,
121
- },
122
- timeout=15,
123
- allow_redirects=False,
124
- )
125
- triggered = resp.status_code in (200, 201, 204, 302)
126
- if not triggered:
127
- logger.error("Jenkins release trigger failed (%s): %s", resp.status_code, resp.text[:200])
128
- return False
129
- logger.info(
130
- "Jenkins Maven Release triggered: %s → %s (next: %s)%s",
131
- repo.cicd_job_url, release_ver, dev_ver,
132
- " — waiting for completion..." if wait else "",
133
- )
134
- if wait:
135
- return _wait_for_jenkins_build(session, repo.cicd_job_url, prev_build_num)
136
- return True
137
- def _maven_release_versions(local_path: str) -> tuple[str, str]:
138
- pom = Path(local_path) / "pom.xml"
139
- if not pom.exists():
140
- return ("", "")
141
- text = pom.read_text(encoding="utf-8", errors="ignore")
142
- m = re.search(r"<version>(\d+\.\d+\.\d+)-SNAPSHOT</version>", text)
143
- if not m:
144
- return ("", "")
145
- parts = m.group(1).split(".")
146
- release_ver = m.group(1)
147
- next_patch = int(parts[2]) + 1
148
- dev_ver = f"{parts[0]}.{parts[1]}.{next_patch}-SNAPSHOT"
149
- return release_ver, dev_ver
150
- def _trigger_github_actions(repo: RepoConfig, fingerprint: str) -> bool:
151
- owner_repo = _owner_repo(repo.repo_url)
152
- url = f"https://api.github.com/repos/{owner_repo}/dispatches"
153
- resp = requests.post(
154
- url,
155
- json={"event_type": "sentinel-deploy", "client_payload": {"fingerprint": fingerprint}},
156
- headers={
157
- "Authorization": f"Bearer {repo.cicd_token}",
158
- "Accept": "application/vnd.github+json",
159
- },
160
- timeout=15,
161
- )
162
- success = resp.status_code == 204
163
- if success:
164
- logger.info("GitHub Actions dispatch sent for %s", owner_repo)
165
- else:
166
- logger.error("GH Actions dispatch failed (%s): %s", resp.status_code, resp.text[:200])
167
- return success
168
- def _owner_repo(repo_url: str) -> str:
169
- if repo_url.startswith("git@"):
170
- return repo_url.split(":")[-1].removesuffix(".git")
171
- return "/".join(repo_url.rstrip("/").split("/")[-2:]).removesuffix(".git")