@misterhuydo/sentinel 1.6.0 → 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.
- package/.cairn/.hint-lock +1 -1
- package/.cairn/session.json +2 -2
- package/package.json +1 -1
- package/python/sentinel/__init__.py +1 -1
- package/python/sentinel/__pycache__/__init__.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/fix_engine.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/main.cpython-311.pyc +0 -0
- package/python/sentinel/fix_engine.py +21 -6
- package/python/sentinel/main.py +131 -1
- package/python/tests/test_fix_engine_cmd.py +53 -0
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-04-
|
|
1
|
+
2026-04-24T10:50:33.264Z
|
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-24T10:58:52.087Z",
|
|
3
|
+
"checkpoint_at": "2026-04-24T10:58:52.089Z",
|
|
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 +1 @@
|
|
|
1
|
-
__version__ = "1.6.
|
|
1
|
+
__version__ = "1.6.1"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -254,12 +254,22 @@ def _is_auth_error(output: str) -> bool:
|
|
|
254
254
|
return any(hint in low for hint in _AUTH_ERROR_HINTS)
|
|
255
255
|
|
|
256
256
|
|
|
257
|
-
def _claude_cmd(
|
|
257
|
+
def _claude_cmd(
|
|
258
|
+
bin_path: str,
|
|
259
|
+
prompt: str,
|
|
260
|
+
session_id: str = "",
|
|
261
|
+
use_bare: bool = True,
|
|
262
|
+
) -> list[str]:
|
|
258
263
|
"""Build the `claude --print` command line.
|
|
259
264
|
|
|
260
265
|
`session_id` (if non-empty) attaches `--resume <id>` so Claude continues an
|
|
261
266
|
existing session — gives prompt-cache reuse and shared context across fixes.
|
|
262
267
|
|
|
268
|
+
`use_bare` controls the `--bare` flag, which forces `ANTHROPIC_API_KEY`-only
|
|
269
|
+
auth. It MUST be True for the API-key attempt (claude needs a key in env)
|
|
270
|
+
and MUST be False for the OAuth attempt (otherwise claude refuses to read
|
|
271
|
+
the cached `claude login` token). The caller picks per attempt.
|
|
272
|
+
|
|
263
273
|
Output is forced to `--output-format json` so the caller can extract the
|
|
264
274
|
session_id, cost, and result text deterministically.
|
|
265
275
|
"""
|
|
@@ -268,10 +278,9 @@ def _claude_cmd(bin_path: str, prompt: str, session_id: str = "") -> list[str]:
|
|
|
268
278
|
skip = _os.getuid() != 0
|
|
269
279
|
except AttributeError:
|
|
270
280
|
skip = True # Windows — always pass flag
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
cmd = [bin_path, "--bare"]
|
|
281
|
+
cmd = [bin_path]
|
|
282
|
+
if use_bare:
|
|
283
|
+
cmd.append("--bare")
|
|
275
284
|
if skip:
|
|
276
285
|
cmd.append("--dangerously-skip-permissions")
|
|
277
286
|
if session_id:
|
|
@@ -346,6 +355,7 @@ def _run_claude_attempt(
|
|
|
346
355
|
on_progress=None,
|
|
347
356
|
cmd_override: list | None = None,
|
|
348
357
|
session_id: str = "",
|
|
358
|
+
use_bare: bool | None = None,
|
|
349
359
|
) -> tuple[str, bool]:
|
|
350
360
|
"""
|
|
351
361
|
Run claude CLI with the given env. Returns (output, timed_out).
|
|
@@ -354,11 +364,16 @@ def _run_claude_attempt(
|
|
|
354
364
|
(deduped — same message not repeated consecutively).
|
|
355
365
|
cmd_override: if provided, use this command list instead of _claude_cmd() default.
|
|
356
366
|
session_id: if provided, passes --resume to Claude to continue an existing session.
|
|
367
|
+
use_bare: if None (default), auto-detect — True iff env carries ANTHROPIC_API_KEY.
|
|
368
|
+
Pass --bare ONLY for the API-key attempt; the OAuth attempt must
|
|
369
|
+
omit it so Claude reads the cached `claude login` token.
|
|
357
370
|
"""
|
|
358
371
|
import threading as _threading
|
|
359
372
|
|
|
373
|
+
if use_bare is None:
|
|
374
|
+
use_bare = bool(env.get("ANTHROPIC_API_KEY"))
|
|
360
375
|
proc = subprocess.Popen(
|
|
361
|
-
cmd_override if cmd_override is not None else _claude_cmd(bin_path, prompt, session_id),
|
|
376
|
+
cmd_override if cmd_override is not None else _claude_cmd(bin_path, prompt, session_id, use_bare=use_bare),
|
|
362
377
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
363
378
|
text=True, env=env, cwd=cwd or None,
|
|
364
379
|
)
|
package/python/sentinel/main.py
CHANGED
|
@@ -628,12 +628,15 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
628
628
|
try:
|
|
629
629
|
patches_dir = Path(sentinel.workspace_dir).resolve() / "patches"
|
|
630
630
|
_loop = asyncio.get_event_loop()
|
|
631
|
+
all_repos = list(cfg_loader.repos.values())
|
|
631
632
|
|
|
632
633
|
# Run blocking subprocess work in thread executor so Boss stays responsive.
|
|
633
634
|
# _progress is thread-safe (HTTP POST) so pass it as streaming callback.
|
|
634
635
|
_progress(":brain: Analyzing with Claude Code...")
|
|
635
636
|
status, patch_path, marker = await _loop.run_in_executor(
|
|
636
|
-
None,
|
|
637
|
+
None,
|
|
638
|
+
lambda: generate_fix(event, repo, sentinel, patches_dir, store,
|
|
639
|
+
_progress, all_repos),
|
|
637
640
|
)
|
|
638
641
|
|
|
639
642
|
submitter_uid = getattr(event, "submitter_user_id", "")
|
|
@@ -652,6 +655,133 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
652
655
|
return {"submitter": submitter_uid, "repo_name": repo.repo_name,
|
|
653
656
|
"status": "blocked", "summary": reason_text[:120], "pr_url": ""}
|
|
654
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
|
+
)
|
|
655
785
|
_progress(f":mag: Patch generated — running tests (`{repo.repo_name}`)...")
|
|
656
786
|
commit_status, commit_hash = await _loop.run_in_executor(
|
|
657
787
|
None, apply_and_commit, event, patch_path, repo, sentinel
|
|
@@ -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"
|