@misterhuydo/sentinel 1.6.9 → 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-
|
|
1
|
+
2026-04-25T09:41:31.525Z
|
package/.cairn/session.json
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-04-
|
|
3
|
-
"checkpoint_at": "2026-04-
|
|
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": 1777039448643.5408
|
|
188
|
+
"J:\\Projects\\Sentinel\\cli\\lib\\test.js": 1774252437350.0059
|
|
191
189
|
}
|
|
192
190
|
}
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.6.
|
|
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)
|
|
@@ -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
|