@misterhuydo/sentinel 1.5.63 → 1.6.0
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/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 +182 -43
- package/python/sentinel/git_manager.py +335 -0
- package/python/sentinel/main.py +189 -5
- package/python/sentinel/state_store.py +121 -0
- package/python/tests/test_cairn_client.py +72 -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/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-22T05:33:19.801Z",
|
|
3
|
+
"checkpoint_at": "2026-04-22T05:33:19.802Z",
|
|
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.0"
|
|
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,7 +254,15 @@ 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(bin_path: str, prompt: str) -> list[str]:
|
|
257
|
+
def _claude_cmd(bin_path: str, prompt: str, session_id: str = "") -> list[str]:
|
|
258
|
+
"""Build the `claude --print` command line.
|
|
259
|
+
|
|
260
|
+
`session_id` (if non-empty) attaches `--resume <id>` so Claude continues an
|
|
261
|
+
existing session — gives prompt-cache reuse and shared context across fixes.
|
|
262
|
+
|
|
263
|
+
Output is forced to `--output-format json` so the caller can extract the
|
|
264
|
+
session_id, cost, and result text deterministically.
|
|
265
|
+
"""
|
|
185
266
|
import os as _os
|
|
186
267
|
try:
|
|
187
268
|
skip = _os.getuid() != 0
|
|
@@ -190,9 +271,13 @@ def _claude_cmd(bin_path: str, prompt: str) -> list[str]:
|
|
|
190
271
|
# --bare: forces ANTHROPIC_API_KEY-only auth, skips keychain/OAuth/hooks.
|
|
191
272
|
# Required on headless servers (EC2) where Claude Code 2.x silently returns
|
|
192
273
|
# empty output when keychain auth fails.
|
|
274
|
+
cmd = [bin_path, "--bare"]
|
|
193
275
|
if skip:
|
|
194
|
-
|
|
195
|
-
|
|
276
|
+
cmd.append("--dangerously-skip-permissions")
|
|
277
|
+
if session_id:
|
|
278
|
+
cmd += ["--resume", session_id]
|
|
279
|
+
cmd += ["--output-format", "json", "--print", prompt]
|
|
280
|
+
return cmd
|
|
196
281
|
|
|
197
282
|
|
|
198
283
|
# ── Claude output → human-readable progress ───────────────────────────────────
|
|
@@ -260,6 +345,7 @@ def _run_claude_attempt(
|
|
|
260
345
|
claude_log_path: Path | None = None,
|
|
261
346
|
on_progress=None,
|
|
262
347
|
cmd_override: list | None = None,
|
|
348
|
+
session_id: str = "",
|
|
263
349
|
) -> tuple[str, bool]:
|
|
264
350
|
"""
|
|
265
351
|
Run claude CLI with the given env. Returns (output, timed_out).
|
|
@@ -267,11 +353,12 @@ def _run_claude_attempt(
|
|
|
267
353
|
If on_progress is given, calls on_progress(msg) for meaningful output lines
|
|
268
354
|
(deduped — same message not repeated consecutively).
|
|
269
355
|
cmd_override: if provided, use this command list instead of _claude_cmd() default.
|
|
356
|
+
session_id: if provided, passes --resume to Claude to continue an existing session.
|
|
270
357
|
"""
|
|
271
358
|
import threading as _threading
|
|
272
359
|
|
|
273
360
|
proc = subprocess.Popen(
|
|
274
|
-
cmd_override if cmd_override is not None else _claude_cmd(bin_path, prompt),
|
|
361
|
+
cmd_override if cmd_override is not None else _claude_cmd(bin_path, prompt, session_id),
|
|
275
362
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
276
363
|
text=True, env=env, cwd=cwd or None,
|
|
277
364
|
)
|
|
@@ -334,6 +421,7 @@ def generate_fix(
|
|
|
334
421
|
patches_dir: Path,
|
|
335
422
|
store=None,
|
|
336
423
|
on_progress=None,
|
|
424
|
+
all_repos: list[RepoConfig] | None = None,
|
|
337
425
|
) -> tuple[str, Path | None, str]:
|
|
338
426
|
"""
|
|
339
427
|
Generate a fix for the given error event.
|
|
@@ -342,20 +430,35 @@ def generate_fix(
|
|
|
342
430
|
(status, patch_path, marker)
|
|
343
431
|
status: "patch" | "skip" | "needs_human" | "error"
|
|
344
432
|
|
|
345
|
-
|
|
433
|
+
Multi-repo & session resume:
|
|
434
|
+
- Claude is invoked from the project root with all sub-repos visible
|
|
435
|
+
through Cairn federation.
|
|
436
|
+
- The saved per-project session id (state_store.claude_sessions) is
|
|
437
|
+
passed via --resume so prompt cache and conversation history are reused.
|
|
438
|
+
- The new session id and cost from the response are persisted back.
|
|
439
|
+
|
|
440
|
+
Auth strategy:
|
|
346
441
|
Primary : Claude Pro (OAuth) if claude_pro_for_tasks=True, else API key
|
|
347
442
|
Fallback : the other method, if primary fails with an auth error
|
|
348
|
-
On total auth failure: notify Slack admins + email report recipients
|
|
349
443
|
"""
|
|
350
444
|
import os as _os
|
|
351
445
|
|
|
446
|
+
if all_repos is None:
|
|
447
|
+
all_repos = [repo]
|
|
448
|
+
|
|
449
|
+
project_root = str(Path(cfg.workspace_dir).parent)
|
|
352
450
|
marker = f"sentinel-{event.fingerprint[:8]}"
|
|
353
451
|
log_file = Path(cfg.workspace_dir) / "fetched" / f"{event.source}.log"
|
|
354
452
|
if not log_file.exists():
|
|
355
453
|
log_file = None
|
|
356
454
|
from .log_syncer import get_synced_files
|
|
357
455
|
synced = get_synced_files(event.source, cfg.workspace_dir)
|
|
358
|
-
prompt = _build_prompt(
|
|
456
|
+
prompt = _build_prompt(
|
|
457
|
+
event, repo, log_file, marker,
|
|
458
|
+
synced_files=synced or None,
|
|
459
|
+
all_repos=all_repos,
|
|
460
|
+
project_root=project_root,
|
|
461
|
+
)
|
|
359
462
|
|
|
360
463
|
# -- Cross-source dedup: skip if fingerprint already fixed in recent git commits ------
|
|
361
464
|
if repo.local_path:
|
|
@@ -374,12 +477,23 @@ def generate_fix(
|
|
|
374
477
|
except Exception as _e:
|
|
375
478
|
logger.debug("fix_engine: git log check failed: %s", _e)
|
|
376
479
|
|
|
480
|
+
# Pull saved session id (per project) so Claude continues an existing session.
|
|
481
|
+
session_id = ""
|
|
482
|
+
if store is not None and getattr(cfg, "project_name", ""):
|
|
483
|
+
try:
|
|
484
|
+
saved = store.get_claude_session(cfg.project_name)
|
|
485
|
+
if saved:
|
|
486
|
+
session_id = saved.get("session_id", "") or ""
|
|
487
|
+
except Exception as _se:
|
|
488
|
+
logger.debug("fix_engine: get_claude_session failed: %s", _se)
|
|
489
|
+
|
|
377
490
|
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
378
491
|
claude_logs_dir = Path(cfg.workspace_dir).parent / "logs" / "claude"
|
|
379
492
|
claude_log_path = claude_logs_dir / f"{event.fingerprint[:8]}-{ts}.log"
|
|
380
493
|
logger.info(
|
|
381
|
-
"Invoking Claude Code for %s (fp=%s) — log: %s",
|
|
494
|
+
"Invoking Claude Code for %s (fp=%s) — log: %s — resume=%s",
|
|
382
495
|
event.source, event.fingerprint, claude_log_path,
|
|
496
|
+
session_id[:8] if session_id else "(new)",
|
|
383
497
|
)
|
|
384
498
|
|
|
385
499
|
base_env = _os.environ.copy()
|
|
@@ -396,20 +510,23 @@ def generate_fix(
|
|
|
396
510
|
else:
|
|
397
511
|
attempts = [("Claude Pro (OAuth)", oauth_env)]
|
|
398
512
|
|
|
399
|
-
|
|
513
|
+
raw_output = ""
|
|
400
514
|
try:
|
|
401
515
|
for label, env in attempts:
|
|
402
516
|
if env is None:
|
|
403
517
|
continue
|
|
404
518
|
logger.info("fix_engine: trying %s for %s", label, event.fingerprint)
|
|
405
|
-
|
|
406
|
-
cfg.claude_code_bin, prompt, env,
|
|
407
|
-
|
|
519
|
+
raw_output, timed_out = _run_claude_attempt(
|
|
520
|
+
cfg.claude_code_bin, prompt, env,
|
|
521
|
+
cwd=project_root, # ← project root (cairn federation)
|
|
522
|
+
claude_log_path=claude_log_path,
|
|
523
|
+
on_progress=on_progress,
|
|
524
|
+
session_id=session_id, # ← resume previous session
|
|
408
525
|
)
|
|
409
526
|
if timed_out:
|
|
410
527
|
logger.error("Claude Code timed out for %s", event.fingerprint)
|
|
411
528
|
return "error", None, ""
|
|
412
|
-
if not _is_auth_error(
|
|
529
|
+
if not _is_auth_error(raw_output):
|
|
413
530
|
break
|
|
414
531
|
logger.warning("fix_engine: %s auth error for %s — trying next method", label, event.fingerprint)
|
|
415
532
|
else:
|
|
@@ -432,14 +549,41 @@ def generate_fix(
|
|
|
432
549
|
slack_alert(cfg.slack_bot_token, cfg.slack_channel, msg)
|
|
433
550
|
return "error", None, ""
|
|
434
551
|
|
|
435
|
-
# Alert Slack immediately on rate-limit — never stay silent
|
|
436
552
|
alert_if_rate_limited(
|
|
437
553
|
cfg.slack_bot_token,
|
|
438
554
|
cfg.slack_channel,
|
|
439
555
|
source=f"fix_engine/{event.fingerprint}",
|
|
440
|
-
output=
|
|
556
|
+
output=raw_output,
|
|
441
557
|
)
|
|
442
558
|
|
|
559
|
+
# Parse the JSON envelope: get session_id, cost, and the actual result text.
|
|
560
|
+
parsed = _parse_claude_json(raw_output)
|
|
561
|
+
if parsed["is_error"] and not parsed["result"]:
|
|
562
|
+
# Fall back to legacy text parsing if JSON decode failed completely.
|
|
563
|
+
# (Older Claude CLI versions may not emit JSON; --output-format flag is ignored.)
|
|
564
|
+
logger.warning(
|
|
565
|
+
"fix_engine: failed to parse JSON output for %s — falling back to raw text",
|
|
566
|
+
event.fingerprint,
|
|
567
|
+
)
|
|
568
|
+
output = raw_output
|
|
569
|
+
else:
|
|
570
|
+
output = parsed["result"]
|
|
571
|
+
|
|
572
|
+
# Persist the session id (and cost delta) regardless of fix outcome — even
|
|
573
|
+
# NEEDS_HUMAN / SKIP turns count toward the conversation history.
|
|
574
|
+
if store is not None and getattr(cfg, "project_name", "") and parsed["session_id"]:
|
|
575
|
+
try:
|
|
576
|
+
store.set_claude_session(
|
|
577
|
+
cfg.project_name, parsed["session_id"],
|
|
578
|
+
cost_delta=parsed["total_cost_usd"],
|
|
579
|
+
)
|
|
580
|
+
logger.info(
|
|
581
|
+
"fix_engine: saved claude session %s for project %s (turn cost $%.4f)",
|
|
582
|
+
parsed["session_id"][:8], cfg.project_name, parsed["total_cost_usd"],
|
|
583
|
+
)
|
|
584
|
+
except Exception as _se:
|
|
585
|
+
logger.warning("fix_engine: set_claude_session failed: %s", _se)
|
|
586
|
+
|
|
443
587
|
if output.strip().upper().startswith("NEEDS_HUMAN:"):
|
|
444
588
|
reason = output.strip()[len("NEEDS_HUMAN:"):].strip()
|
|
445
589
|
logger.info("Claude needs human for %s: %s", event.fingerprint, reason[:200])
|
|
@@ -478,11 +622,6 @@ def generate_fix(
|
|
|
478
622
|
|
|
479
623
|
patch = _fix_blank_context_lines(patch)
|
|
480
624
|
|
|
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
625
|
patches_dir.mkdir(parents=True, exist_ok=True)
|
|
487
626
|
patch_path = patches_dir / f"{event.fingerprint}.diff"
|
|
488
627
|
patch_path.write_text(patch, encoding="utf-8")
|