@misterhuydo/sentinel 1.5.63 → 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/scripts/__pycache__/fix_ask_codebase_context.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_ask_codebase_stdin.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_chain_slack.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_fstring.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_knowledge_cache.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_knowledge_cache_staleness.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_merge_confirm.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_permission_messages.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_pr_check_head_detect.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_pr_msg_newlines.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_pr_tracking_boss.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_pr_tracking_db.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_pr_tracking_main.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_project_isolation.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_system_prompt.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/fix_two_bugs.cpython-311.pyc +0 -0
- package/python/scripts/__pycache__/patch_chain_release.cpython-311.pyc +0 -0
- package/python/sentinel/__init__.py +1 -1
- package/python/sentinel/__pycache__/__init__.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/cairn_client.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/cicd_trigger.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/config_loader.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/dependency_manager.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/dev_watcher.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/fix_engine.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/git_manager.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/health_checker.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/issue_watcher.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/log_fetcher.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/log_parser.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/log_syncer.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/main.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/notify.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/repo_router.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/repo_task_engine.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/reporter.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/sentinel_dev.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/slack_bot.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/state_store.cpython-311.pyc +0 -0
- package/python/sentinel/cairn_client.py +30 -11
- package/python/sentinel/fix_engine.py +200 -46
- package/python/sentinel/git_manager.py +335 -0
- package/python/sentinel/main.py +320 -6
- package/python/sentinel/state_store.py +121 -0
- package/python/tests/test_cairn_client.py +72 -0
- package/python/tests/test_fix_engine_cmd.py +53 -0
- package/python/tests/test_fix_engine_json.py +95 -0
- package/python/tests/test_fix_engine_prompt.py +93 -0
- package/python/tests/test_multi_repo_apply.py +254 -0
- package/python/tests/test_multi_repo_publish.py +175 -0
- package/python/tests/test_patch_parser.py +250 -0
- package/python/tests/test_project_lock.py +85 -0
- package/python/tests/test_state_store.py +87 -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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.
|
|
1
|
+
__version__ = "1.6.1"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -37,30 +37,49 @@ def ensure_installed() -> bool:
|
|
|
37
37
|
return False
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def
|
|
41
|
-
"""Run `cairn install` in
|
|
40
|
+
def _install_cairn_at(path: str) -> bool:
|
|
41
|
+
"""Run `cairn install` in `path` if not already initialised.
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
Idempotent: returns True (no-op) if the .cairn-project marker already exists.
|
|
44
|
+
Returns False if the directory doesn't exist or `cairn install` failed.
|
|
45
45
|
"""
|
|
46
46
|
import os
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
if not os.path.isdir(path):
|
|
48
|
+
logger.warning("cairn init: directory missing: %s", path)
|
|
49
|
+
return False
|
|
50
|
+
marker = os.path.join(path, ".cairn", ".cairn-project")
|
|
51
|
+
if os.path.exists(marker):
|
|
52
|
+
logger.debug("cairn already installed in %s", path)
|
|
50
53
|
return True
|
|
51
54
|
try:
|
|
52
55
|
r = subprocess.run(
|
|
53
56
|
[CAIRN_BIN, "install"],
|
|
54
|
-
cwd=
|
|
57
|
+
cwd=path,
|
|
55
58
|
capture_output=True,
|
|
56
59
|
text=True,
|
|
57
60
|
timeout=30,
|
|
58
61
|
)
|
|
59
62
|
if r.returncode == 0:
|
|
60
|
-
logger.info("cairn installed in %s",
|
|
63
|
+
logger.info("cairn installed in %s", path)
|
|
61
64
|
return True
|
|
62
|
-
logger.warning("cairn install failed in %s: %s",
|
|
65
|
+
logger.warning("cairn install failed in %s: %s", path, r.stderr.strip())
|
|
63
66
|
return False
|
|
64
67
|
except Exception as e:
|
|
65
|
-
logger.warning("cairn install error in %s: %s",
|
|
68
|
+
logger.warning("cairn install error in %s: %s", path, e)
|
|
66
69
|
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def init_project_root(project_dir: str) -> bool:
|
|
73
|
+
"""Initialise the project-root .cairn/ so child sub-repos federate up to it.
|
|
74
|
+
|
|
75
|
+
Once both the project root and its sub-repos have .cairn/, Cairn's parent
|
|
76
|
+
walk-up at query time auto-mounts every sibling sub-index — giving Claude
|
|
77
|
+
full cross-repo visibility from the project root cwd. See cairn db.js
|
|
78
|
+
`mountParentSubIndexes` for the federation mechanism.
|
|
79
|
+
"""
|
|
80
|
+
return _install_cairn_at(project_dir)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def index_repo(repo: RepoConfig) -> bool:
|
|
84
|
+
"""Run `cairn install` in the repo if not already initialised."""
|
|
85
|
+
return _install_cairn_at(repo.local_path)
|
|
@@ -8,6 +8,7 @@ connection — Sentinel does not need to query or inject it explicitly.
|
|
|
8
8
|
"""
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import json
|
|
11
12
|
import logging
|
|
12
13
|
import re
|
|
13
14
|
import subprocess
|
|
@@ -22,13 +23,52 @@ from .notify import alert_if_rate_limited, slack_alert
|
|
|
22
23
|
logger = logging.getLogger(__name__)
|
|
23
24
|
|
|
24
25
|
SUBPROCESS_TIMEOUT = 600
|
|
25
|
-
MAX_FILES_IN_PATCH = 5
|
|
26
|
-
MAX_LINES_IN_PATCH = 200
|
|
27
26
|
|
|
28
27
|
_DIFF_BLOCK = re.compile(r"```(?:diff|patch)?\n(.*?)```", re.DOTALL)
|
|
29
28
|
_DIFF_HEADER = re.compile(r"^diff --git|^---\s+\S+|^\+\+\+\s+\S+", re.MULTILINE)
|
|
30
29
|
|
|
31
30
|
|
|
31
|
+
def _parse_claude_json(stdout: str) -> dict:
|
|
32
|
+
"""Parse `claude --print --output-format json` output.
|
|
33
|
+
|
|
34
|
+
Tolerant of leading garbage (debug lines, warnings) — locates the first JSON
|
|
35
|
+
object in the stream. Always returns the same shape regardless of which
|
|
36
|
+
fields claude emitted:
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
"result": str, # the model's response text (where the patch lives)
|
|
40
|
+
"session_id": str, # for --resume on the next call
|
|
41
|
+
"total_cost_usd": float,
|
|
42
|
+
"is_error": bool, # True if claude reported an error OR parse failed
|
|
43
|
+
}
|
|
44
|
+
"""
|
|
45
|
+
empty = {"result": "", "session_id": "", "total_cost_usd": 0.0, "is_error": True}
|
|
46
|
+
if not stdout or not stdout.strip():
|
|
47
|
+
return empty
|
|
48
|
+
|
|
49
|
+
text = stdout.strip()
|
|
50
|
+
start = text.find("{")
|
|
51
|
+
if start < 0:
|
|
52
|
+
return empty
|
|
53
|
+
try:
|
|
54
|
+
obj = json.loads(text[start:])
|
|
55
|
+
except json.JSONDecodeError:
|
|
56
|
+
# Some claude versions append trailing data; consume only the leading object.
|
|
57
|
+
try:
|
|
58
|
+
obj, _end = json.JSONDecoder().raw_decode(text[start:])
|
|
59
|
+
except json.JSONDecodeError:
|
|
60
|
+
return empty
|
|
61
|
+
|
|
62
|
+
if not isinstance(obj, dict):
|
|
63
|
+
return empty
|
|
64
|
+
return {
|
|
65
|
+
"result": obj.get("result", "") or "",
|
|
66
|
+
"session_id": obj.get("session_id", "") or "",
|
|
67
|
+
"total_cost_usd": float(obj.get("total_cost_usd", 0.0) or 0.0),
|
|
68
|
+
"is_error": bool(obj.get("is_error", False)),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
32
72
|
def _extract_patch(output: str) -> str | None:
|
|
33
73
|
"""Extract a unified diff patch from Claude's output."""
|
|
34
74
|
# 1. Prefer a fenced ```diff or ```patch block
|
|
@@ -48,8 +88,28 @@ def _extract_patch(output: str) -> str | None:
|
|
|
48
88
|
return None
|
|
49
89
|
|
|
50
90
|
|
|
51
|
-
def _build_prompt(
|
|
52
|
-
|
|
91
|
+
def _build_prompt(
|
|
92
|
+
event,
|
|
93
|
+
primary_repo: RepoConfig,
|
|
94
|
+
log_file,
|
|
95
|
+
marker: str,
|
|
96
|
+
stale_markers: list[str] | None = None,
|
|
97
|
+
synced_files: list | None = None,
|
|
98
|
+
all_repos: list[RepoConfig] | None = None,
|
|
99
|
+
project_root: str = "",
|
|
100
|
+
) -> str:
|
|
101
|
+
"""Build the fix prompt for the multi-repo project.
|
|
102
|
+
|
|
103
|
+
`primary_repo` is where the error originated; `all_repos` are every repo in
|
|
104
|
+
the project visible to Claude via Cairn federation. Patch paths must be
|
|
105
|
+
`repos/<repo-name>/...` so git_manager.parse_multi_repo_patch() can split
|
|
106
|
+
the result back per-repo.
|
|
107
|
+
"""
|
|
108
|
+
if all_repos is None:
|
|
109
|
+
all_repos = [primary_repo]
|
|
110
|
+
project_root = project_root or str(Path(primary_repo.local_path).parent.parent)
|
|
111
|
+
|
|
112
|
+
if log_file and Path(log_file).exists():
|
|
53
113
|
ctx = (
|
|
54
114
|
"LOG FILE: " + str(log_file) + "\n"
|
|
55
115
|
"Read this file first -- it contains the last 48h of logs from "
|
|
@@ -93,9 +153,15 @@ def _build_prompt(event, repo: RepoConfig, log_file, marker: str, stale_markers:
|
|
|
93
153
|
"Commit the cleanup separately with message: 'chore(sentinel): remove stale markers'\n"
|
|
94
154
|
)
|
|
95
155
|
|
|
156
|
+
repo_listing = "\n".join(f" - {r.repo_name}" for r in all_repos)
|
|
157
|
+
|
|
96
158
|
lines_out = [
|
|
97
|
-
f"You are fixing a production bug in the
|
|
98
|
-
|
|
159
|
+
f"You are fixing a production bug in the project at {project_root}.",
|
|
160
|
+
"",
|
|
161
|
+
"PROJECT REPOS (all visible to you via Cairn federation):",
|
|
162
|
+
repo_listing,
|
|
163
|
+
"",
|
|
164
|
+
f"The error originated in: {primary_repo.repo_name}",
|
|
99
165
|
"",
|
|
100
166
|
]
|
|
101
167
|
if cleanup:
|
|
@@ -109,6 +175,8 @@ def _build_prompt(event, repo: RepoConfig, log_file, marker: str, stale_markers:
|
|
|
109
175
|
"Task:",
|
|
110
176
|
f"1. {step1}",
|
|
111
177
|
"2. Use your available tools to explore the codebase and identify the root cause.",
|
|
178
|
+
" You can read across ALL listed repos — use that visibility to follow type",
|
|
179
|
+
" definitions, callers, or shared library code that may be involved.",
|
|
112
180
|
f"3. {marker_instruction}",
|
|
113
181
|
"4. Consider all possible fix approaches. For each, weigh:",
|
|
114
182
|
" - Confidence: is this definitely the root cause?",
|
|
@@ -116,9 +184,22 @@ def _build_prompt(event, repo: RepoConfig, log_file, marker: str, stale_markers:
|
|
|
116
184
|
" - Scope: is it minimal and targeted?",
|
|
117
185
|
" Choose the safest minimal approach. If multiple valid options exist, pick the one",
|
|
118
186
|
" with highest confidence and lowest blast radius.",
|
|
119
|
-
"
|
|
120
|
-
"
|
|
121
|
-
"
|
|
187
|
+
"",
|
|
188
|
+
"PATCH OUTPUT FORMAT (multi-repo aware)",
|
|
189
|
+
"5. Output a unified diff. EVERY path must be prefixed with `repos/<repo-name>/`",
|
|
190
|
+
" so Sentinel can split the patch back per repo. Examples:",
|
|
191
|
+
" diff --git a/repos/Whydah-TypeLib/src/Foo.java b/repos/Whydah-TypeLib/src/Foo.java",
|
|
192
|
+
" --- a/repos/1881-SSOLoginWebApp/pom.xml",
|
|
193
|
+
" +++ b/repos/1881-SSOLoginWebApp/pom.xml",
|
|
194
|
+
"6. If your fix touches MORE THAN ONE repo, prepend a single header line at the",
|
|
195
|
+
" very top of the patch (before any `diff --git`):",
|
|
196
|
+
" # Affected repos: <repo-a>, <repo-b>",
|
|
197
|
+
" Order matters: list the LIBRARY/dependency repo FIRST, the CONSUMER repo",
|
|
198
|
+
" AFTER. Sentinel will merge in this order.",
|
|
199
|
+
"7. Do not explain. Output only the patch (and the header if multi-repo).",
|
|
200
|
+
"",
|
|
201
|
+
"ESCALATION SIGNALS",
|
|
202
|
+
"8. Only if you truly cannot produce a safe fix — e.g. the root cause requires a",
|
|
122
203
|
" DB schema change, infrastructure update, business logic decision, or is inside",
|
|
123
204
|
" a third-party library — output exactly:",
|
|
124
205
|
" NEEDS_HUMAN: <explanation>",
|
|
@@ -126,10 +207,15 @@ def _build_prompt(event, repo: RepoConfig, log_file, marker: str, stale_markers:
|
|
|
126
207
|
" was insufficient or unsafe, (c) exactly what a human needs to do or decide.",
|
|
127
208
|
" Do NOT output NEEDS_HUMAN just because the fix is complex — only when human",
|
|
128
209
|
" judgement or access is genuinely required.",
|
|
129
|
-
"
|
|
210
|
+
"9. If the fix requires changing Sentinel's own source code (the monitoring/fix agent",
|
|
130
211
|
" itself, not the application being monitored) — output exactly:",
|
|
131
212
|
" BOSS_ESCALATE: <description of what needs to change in Sentinel>",
|
|
132
213
|
" This escalates to Patch, the Sentinel dev agent, who will implement it.",
|
|
214
|
+
"",
|
|
215
|
+
"FINAL STEP — checkpoint cairn",
|
|
216
|
+
"10. Before you stop, call the `cairn_checkpoint` MCP tool with a one-line summary",
|
|
217
|
+
" of what you changed (e.g. \"Broaden FirstName regex; bump consumer to 2.7.1\").",
|
|
218
|
+
" This lets the next session resume with full context of prior fixes.",
|
|
133
219
|
]
|
|
134
220
|
return "\n".join(lines_out)
|
|
135
221
|
|
|
@@ -156,19 +242,6 @@ def _fix_blank_context_lines(patch: str) -> str:
|
|
|
156
242
|
return "\n".join(result) + "\n"
|
|
157
243
|
|
|
158
244
|
|
|
159
|
-
def _validate_patch(patch: str) -> tuple[bool, str]:
|
|
160
|
-
files_changed = len(re.findall(r"^diff --git", patch, re.MULTILINE))
|
|
161
|
-
lines_changed = len([
|
|
162
|
-
l for l in patch.splitlines()
|
|
163
|
-
if l.startswith(("+", "-")) and not l.startswith(("+++", "---"))
|
|
164
|
-
])
|
|
165
|
-
if files_changed > MAX_FILES_IN_PATCH:
|
|
166
|
-
return False, f"Patch touches {files_changed} files (limit {MAX_FILES_IN_PATCH})"
|
|
167
|
-
if lines_changed > MAX_LINES_IN_PATCH:
|
|
168
|
-
return False, f"Patch changes {lines_changed} lines (limit {MAX_LINES_IN_PATCH})"
|
|
169
|
-
return True, ""
|
|
170
|
-
|
|
171
|
-
|
|
172
245
|
_AUTH_ERROR_HINTS = (
|
|
173
246
|
"not logged in", "please run claude login", "authentication failed",
|
|
174
247
|
"api key is not set", "invalid x-api-key", "unauthorized", "please authenticate",
|
|
@@ -181,18 +254,39 @@ def _is_auth_error(output: str) -> bool:
|
|
|
181
254
|
return any(hint in low for hint in _AUTH_ERROR_HINTS)
|
|
182
255
|
|
|
183
256
|
|
|
184
|
-
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]:
|
|
263
|
+
"""Build the `claude --print` command line.
|
|
264
|
+
|
|
265
|
+
`session_id` (if non-empty) attaches `--resume <id>` so Claude continues an
|
|
266
|
+
existing session — gives prompt-cache reuse and shared context across fixes.
|
|
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
|
+
|
|
273
|
+
Output is forced to `--output-format json` so the caller can extract the
|
|
274
|
+
session_id, cost, and result text deterministically.
|
|
275
|
+
"""
|
|
185
276
|
import os as _os
|
|
186
277
|
try:
|
|
187
278
|
skip = _os.getuid() != 0
|
|
188
279
|
except AttributeError:
|
|
189
280
|
skip = True # Windows — always pass flag
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
281
|
+
cmd = [bin_path]
|
|
282
|
+
if use_bare:
|
|
283
|
+
cmd.append("--bare")
|
|
193
284
|
if skip:
|
|
194
|
-
|
|
195
|
-
|
|
285
|
+
cmd.append("--dangerously-skip-permissions")
|
|
286
|
+
if session_id:
|
|
287
|
+
cmd += ["--resume", session_id]
|
|
288
|
+
cmd += ["--output-format", "json", "--print", prompt]
|
|
289
|
+
return cmd
|
|
196
290
|
|
|
197
291
|
|
|
198
292
|
# ── Claude output → human-readable progress ───────────────────────────────────
|
|
@@ -260,6 +354,8 @@ def _run_claude_attempt(
|
|
|
260
354
|
claude_log_path: Path | None = None,
|
|
261
355
|
on_progress=None,
|
|
262
356
|
cmd_override: list | None = None,
|
|
357
|
+
session_id: str = "",
|
|
358
|
+
use_bare: bool | None = None,
|
|
263
359
|
) -> tuple[str, bool]:
|
|
264
360
|
"""
|
|
265
361
|
Run claude CLI with the given env. Returns (output, timed_out).
|
|
@@ -267,11 +363,17 @@ def _run_claude_attempt(
|
|
|
267
363
|
If on_progress is given, calls on_progress(msg) for meaningful output lines
|
|
268
364
|
(deduped — same message not repeated consecutively).
|
|
269
365
|
cmd_override: if provided, use this command list instead of _claude_cmd() default.
|
|
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.
|
|
270
370
|
"""
|
|
271
371
|
import threading as _threading
|
|
272
372
|
|
|
373
|
+
if use_bare is None:
|
|
374
|
+
use_bare = bool(env.get("ANTHROPIC_API_KEY"))
|
|
273
375
|
proc = subprocess.Popen(
|
|
274
|
-
cmd_override if cmd_override is not None else _claude_cmd(bin_path, prompt),
|
|
376
|
+
cmd_override if cmd_override is not None else _claude_cmd(bin_path, prompt, session_id, use_bare=use_bare),
|
|
275
377
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
276
378
|
text=True, env=env, cwd=cwd or None,
|
|
277
379
|
)
|
|
@@ -334,6 +436,7 @@ def generate_fix(
|
|
|
334
436
|
patches_dir: Path,
|
|
335
437
|
store=None,
|
|
336
438
|
on_progress=None,
|
|
439
|
+
all_repos: list[RepoConfig] | None = None,
|
|
337
440
|
) -> tuple[str, Path | None, str]:
|
|
338
441
|
"""
|
|
339
442
|
Generate a fix for the given error event.
|
|
@@ -342,20 +445,35 @@ def generate_fix(
|
|
|
342
445
|
(status, patch_path, marker)
|
|
343
446
|
status: "patch" | "skip" | "needs_human" | "error"
|
|
344
447
|
|
|
345
|
-
|
|
448
|
+
Multi-repo & session resume:
|
|
449
|
+
- Claude is invoked from the project root with all sub-repos visible
|
|
450
|
+
through Cairn federation.
|
|
451
|
+
- The saved per-project session id (state_store.claude_sessions) is
|
|
452
|
+
passed via --resume so prompt cache and conversation history are reused.
|
|
453
|
+
- The new session id and cost from the response are persisted back.
|
|
454
|
+
|
|
455
|
+
Auth strategy:
|
|
346
456
|
Primary : Claude Pro (OAuth) if claude_pro_for_tasks=True, else API key
|
|
347
457
|
Fallback : the other method, if primary fails with an auth error
|
|
348
|
-
On total auth failure: notify Slack admins + email report recipients
|
|
349
458
|
"""
|
|
350
459
|
import os as _os
|
|
351
460
|
|
|
461
|
+
if all_repos is None:
|
|
462
|
+
all_repos = [repo]
|
|
463
|
+
|
|
464
|
+
project_root = str(Path(cfg.workspace_dir).parent)
|
|
352
465
|
marker = f"sentinel-{event.fingerprint[:8]}"
|
|
353
466
|
log_file = Path(cfg.workspace_dir) / "fetched" / f"{event.source}.log"
|
|
354
467
|
if not log_file.exists():
|
|
355
468
|
log_file = None
|
|
356
469
|
from .log_syncer import get_synced_files
|
|
357
470
|
synced = get_synced_files(event.source, cfg.workspace_dir)
|
|
358
|
-
prompt = _build_prompt(
|
|
471
|
+
prompt = _build_prompt(
|
|
472
|
+
event, repo, log_file, marker,
|
|
473
|
+
synced_files=synced or None,
|
|
474
|
+
all_repos=all_repos,
|
|
475
|
+
project_root=project_root,
|
|
476
|
+
)
|
|
359
477
|
|
|
360
478
|
# -- Cross-source dedup: skip if fingerprint already fixed in recent git commits ------
|
|
361
479
|
if repo.local_path:
|
|
@@ -374,12 +492,23 @@ def generate_fix(
|
|
|
374
492
|
except Exception as _e:
|
|
375
493
|
logger.debug("fix_engine: git log check failed: %s", _e)
|
|
376
494
|
|
|
495
|
+
# Pull saved session id (per project) so Claude continues an existing session.
|
|
496
|
+
session_id = ""
|
|
497
|
+
if store is not None and getattr(cfg, "project_name", ""):
|
|
498
|
+
try:
|
|
499
|
+
saved = store.get_claude_session(cfg.project_name)
|
|
500
|
+
if saved:
|
|
501
|
+
session_id = saved.get("session_id", "") or ""
|
|
502
|
+
except Exception as _se:
|
|
503
|
+
logger.debug("fix_engine: get_claude_session failed: %s", _se)
|
|
504
|
+
|
|
377
505
|
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
378
506
|
claude_logs_dir = Path(cfg.workspace_dir).parent / "logs" / "claude"
|
|
379
507
|
claude_log_path = claude_logs_dir / f"{event.fingerprint[:8]}-{ts}.log"
|
|
380
508
|
logger.info(
|
|
381
|
-
"Invoking Claude Code for %s (fp=%s) — log: %s",
|
|
509
|
+
"Invoking Claude Code for %s (fp=%s) — log: %s — resume=%s",
|
|
382
510
|
event.source, event.fingerprint, claude_log_path,
|
|
511
|
+
session_id[:8] if session_id else "(new)",
|
|
383
512
|
)
|
|
384
513
|
|
|
385
514
|
base_env = _os.environ.copy()
|
|
@@ -396,20 +525,23 @@ def generate_fix(
|
|
|
396
525
|
else:
|
|
397
526
|
attempts = [("Claude Pro (OAuth)", oauth_env)]
|
|
398
527
|
|
|
399
|
-
|
|
528
|
+
raw_output = ""
|
|
400
529
|
try:
|
|
401
530
|
for label, env in attempts:
|
|
402
531
|
if env is None:
|
|
403
532
|
continue
|
|
404
533
|
logger.info("fix_engine: trying %s for %s", label, event.fingerprint)
|
|
405
|
-
|
|
406
|
-
cfg.claude_code_bin, prompt, env,
|
|
407
|
-
|
|
534
|
+
raw_output, timed_out = _run_claude_attempt(
|
|
535
|
+
cfg.claude_code_bin, prompt, env,
|
|
536
|
+
cwd=project_root, # ← project root (cairn federation)
|
|
537
|
+
claude_log_path=claude_log_path,
|
|
538
|
+
on_progress=on_progress,
|
|
539
|
+
session_id=session_id, # ← resume previous session
|
|
408
540
|
)
|
|
409
541
|
if timed_out:
|
|
410
542
|
logger.error("Claude Code timed out for %s", event.fingerprint)
|
|
411
543
|
return "error", None, ""
|
|
412
|
-
if not _is_auth_error(
|
|
544
|
+
if not _is_auth_error(raw_output):
|
|
413
545
|
break
|
|
414
546
|
logger.warning("fix_engine: %s auth error for %s — trying next method", label, event.fingerprint)
|
|
415
547
|
else:
|
|
@@ -432,14 +564,41 @@ def generate_fix(
|
|
|
432
564
|
slack_alert(cfg.slack_bot_token, cfg.slack_channel, msg)
|
|
433
565
|
return "error", None, ""
|
|
434
566
|
|
|
435
|
-
# Alert Slack immediately on rate-limit — never stay silent
|
|
436
567
|
alert_if_rate_limited(
|
|
437
568
|
cfg.slack_bot_token,
|
|
438
569
|
cfg.slack_channel,
|
|
439
570
|
source=f"fix_engine/{event.fingerprint}",
|
|
440
|
-
output=
|
|
571
|
+
output=raw_output,
|
|
441
572
|
)
|
|
442
573
|
|
|
574
|
+
# Parse the JSON envelope: get session_id, cost, and the actual result text.
|
|
575
|
+
parsed = _parse_claude_json(raw_output)
|
|
576
|
+
if parsed["is_error"] and not parsed["result"]:
|
|
577
|
+
# Fall back to legacy text parsing if JSON decode failed completely.
|
|
578
|
+
# (Older Claude CLI versions may not emit JSON; --output-format flag is ignored.)
|
|
579
|
+
logger.warning(
|
|
580
|
+
"fix_engine: failed to parse JSON output for %s — falling back to raw text",
|
|
581
|
+
event.fingerprint,
|
|
582
|
+
)
|
|
583
|
+
output = raw_output
|
|
584
|
+
else:
|
|
585
|
+
output = parsed["result"]
|
|
586
|
+
|
|
587
|
+
# Persist the session id (and cost delta) regardless of fix outcome — even
|
|
588
|
+
# NEEDS_HUMAN / SKIP turns count toward the conversation history.
|
|
589
|
+
if store is not None and getattr(cfg, "project_name", "") and parsed["session_id"]:
|
|
590
|
+
try:
|
|
591
|
+
store.set_claude_session(
|
|
592
|
+
cfg.project_name, parsed["session_id"],
|
|
593
|
+
cost_delta=parsed["total_cost_usd"],
|
|
594
|
+
)
|
|
595
|
+
logger.info(
|
|
596
|
+
"fix_engine: saved claude session %s for project %s (turn cost $%.4f)",
|
|
597
|
+
parsed["session_id"][:8], cfg.project_name, parsed["total_cost_usd"],
|
|
598
|
+
)
|
|
599
|
+
except Exception as _se:
|
|
600
|
+
logger.warning("fix_engine: set_claude_session failed: %s", _se)
|
|
601
|
+
|
|
443
602
|
if output.strip().upper().startswith("NEEDS_HUMAN:"):
|
|
444
603
|
reason = output.strip()[len("NEEDS_HUMAN:"):].strip()
|
|
445
604
|
logger.info("Claude needs human for %s: %s", event.fingerprint, reason[:200])
|
|
@@ -478,11 +637,6 @@ def generate_fix(
|
|
|
478
637
|
|
|
479
638
|
patch = _fix_blank_context_lines(patch)
|
|
480
639
|
|
|
481
|
-
ok, reason = _validate_patch(patch)
|
|
482
|
-
if not ok:
|
|
483
|
-
logger.warning("Patch rejected for %s: %s", event.fingerprint, reason)
|
|
484
|
-
return "skip", None, ""
|
|
485
|
-
|
|
486
640
|
patches_dir.mkdir(parents=True, exist_ok=True)
|
|
487
641
|
patch_path = patches_dir / f"{event.fingerprint}.diff"
|
|
488
642
|
patch_path.write_text(patch, encoding="utf-8")
|