@misterhuydo/sentinel 1.6.11 → 1.6.13
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__/fix_engine.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/git_manager.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/main.cpython-311.pyc +0 -0
- package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
- package/python/sentinel/fix_engine.py +10 -0
- package/python/sentinel/git_manager.py +246 -7
- package/python/sentinel/main.py +5 -1
- package/python/sentinel/sentinel_boss.py +21 -0
- package/python/tests/test_patch_repair.py +240 -0
- 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/__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__/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__/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_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/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-04-
|
|
1
|
+
2026-04-27T17:46:15.506Z
|
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-27T17:47:43.806Z",
|
|
3
|
+
"checkpoint_at": "2026-04-27T17:47:43.808Z",
|
|
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.13"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -206,6 +206,16 @@ def _build_prompt(
|
|
|
206
206
|
" AFTER. Sentinel will merge in this order.",
|
|
207
207
|
"7. Do not explain. Output only the patch (and the header if multi-repo).",
|
|
208
208
|
"",
|
|
209
|
+
"HUNK CONTEXT — do NOT extend context to EOF",
|
|
210
|
+
" End each hunk's context immediately after your last `+`/`-` change.",
|
|
211
|
+
" Specifically: do NOT include the class-closing `}` (or any trailing",
|
|
212
|
+
" blank/whitespace-only lines that lead to it) as context. Many files",
|
|
213
|
+
" have stray blank lines or trailing tabs between the last method's",
|
|
214
|
+
" closing brace and the file's final brace; if you anchor context on",
|
|
215
|
+
" those, your line counts won't match and `git apply` will reject the",
|
|
216
|
+
" patch. Keep hunks tight: 3 lines of context before/after the change",
|
|
217
|
+
" is plenty.",
|
|
218
|
+
"",
|
|
209
219
|
"ESCALATION SIGNALS",
|
|
210
220
|
"8. Only if you truly cannot produce a safe fix — e.g. the root cause requires a",
|
|
211
221
|
" DB schema change, infrastructure update, business logic decision, or is inside",
|
|
@@ -104,6 +104,215 @@ def parse_multi_repo_patch(combined_patch: str) -> dict:
|
|
|
104
104
|
return {"affected_repos": ordered, "patches": patches, "summary": summary}
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
# ── Patch rewriter: splice missing intermediate context back in ───────────────
|
|
108
|
+
#
|
|
109
|
+
# LLM-generated diffs sometimes drop blank or whitespace-only lines from a
|
|
110
|
+
# hunk's context — most often the trailing whitespace between a method's
|
|
111
|
+
# closing brace and the file's class-closing brace. `git apply` requires
|
|
112
|
+
# context to match the file *contiguously*, so a missing line breaks the
|
|
113
|
+
# whole hunk. `--ignore-whitespace` ignores whitespace within a line; it does
|
|
114
|
+
# NOT let context skip an entire line. The rewriter reads the actual file
|
|
115
|
+
# and splices the skipped lines back as ' ' context.
|
|
116
|
+
|
|
117
|
+
_HUNK_HEADER_RE = re.compile(
|
|
118
|
+
r"^@@ -(?P<old_start>\d+)(?:,(?P<old_count>\d+))? \+(?P<new_start>\d+)(?:,(?P<new_count>\d+))? @@"
|
|
119
|
+
)
|
|
120
|
+
_PATCH_FILE_HEADER_RE = re.compile(r"^\+\+\+ b/(.+?)\s*$", re.MULTILINE)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _line_match(file_line: str, patch_body: str) -> bool:
|
|
124
|
+
"""Compare ignoring trailing whitespace and CR (LF mismatch tolerant)."""
|
|
125
|
+
return file_line.rstrip() == patch_body.rstrip()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _strip_eol(s: str) -> str:
|
|
129
|
+
if s.endswith("\r\n"):
|
|
130
|
+
return s[:-2]
|
|
131
|
+
if s.endswith("\n") or s.endswith("\r"):
|
|
132
|
+
return s[:-1]
|
|
133
|
+
return s
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _repair_hunk_body(
|
|
137
|
+
file_lines: list[str],
|
|
138
|
+
anchor: int,
|
|
139
|
+
body_lines: list[str],
|
|
140
|
+
window: int = 8,
|
|
141
|
+
) -> tuple[list[str], int]:
|
|
142
|
+
"""Walk the hunk body against the actual file and splice missing context.
|
|
143
|
+
|
|
144
|
+
`body_lines` are full patch lines (with leading op char + newline).
|
|
145
|
+
`anchor` is the 1-based line number from `@@ -N`. Returns
|
|
146
|
+
(repaired_body_lines, repaired_anchor). Lines for which no match is
|
|
147
|
+
found within `window` are left alone — git apply will surface those.
|
|
148
|
+
"""
|
|
149
|
+
# Detect the EOL style used by the body so spliced context lines match.
|
|
150
|
+
eol = "\n"
|
|
151
|
+
for ln in body_lines:
|
|
152
|
+
if ln.endswith("\r\n"):
|
|
153
|
+
eol = "\r\n"; break
|
|
154
|
+
if ln.endswith("\n"):
|
|
155
|
+
eol = "\n"; break
|
|
156
|
+
|
|
157
|
+
# Calibrate the anchor: search for the first ' '/'-' line within +/- window
|
|
158
|
+
# of the declared start. Most LLMs get the line number close-but-not-exact.
|
|
159
|
+
first_body: str | None = None
|
|
160
|
+
for ln in body_lines:
|
|
161
|
+
if not ln or ln in ("\n", "\r\n"):
|
|
162
|
+
first_body = ""; break
|
|
163
|
+
op = ln[0]
|
|
164
|
+
if op in (" ", "-"):
|
|
165
|
+
first_body = _strip_eol(ln[1:])
|
|
166
|
+
break
|
|
167
|
+
if op == "+":
|
|
168
|
+
continue
|
|
169
|
+
pos = max(0, anchor - 1)
|
|
170
|
+
if first_body is not None:
|
|
171
|
+
candidates: list[int] = []
|
|
172
|
+
for offset in range(-window, window + 1):
|
|
173
|
+
p = (anchor - 1) + offset
|
|
174
|
+
if 0 <= p < len(file_lines) and _line_match(file_lines[p], first_body):
|
|
175
|
+
candidates.append(p)
|
|
176
|
+
if candidates:
|
|
177
|
+
pos = min(candidates, key=lambda p: abs(p - (anchor - 1)))
|
|
178
|
+
|
|
179
|
+
new_anchor = pos + 1
|
|
180
|
+
|
|
181
|
+
out: list[str] = []
|
|
182
|
+
for ln in body_lines:
|
|
183
|
+
if not ln:
|
|
184
|
+
continue
|
|
185
|
+
if ln in ("\n", "\r\n"):
|
|
186
|
+
# Bare newline = blank context (treat as " ")
|
|
187
|
+
op, body = " ", ""
|
|
188
|
+
else:
|
|
189
|
+
op = ln[0]
|
|
190
|
+
body = _strip_eol(ln[1:])
|
|
191
|
+
if op == "+":
|
|
192
|
+
out.append(ln)
|
|
193
|
+
continue
|
|
194
|
+
if op == "\\": # ""
|
|
195
|
+
out.append(ln)
|
|
196
|
+
continue
|
|
197
|
+
# ' ' or '-': must match the next file line.
|
|
198
|
+
if pos < len(file_lines) and _line_match(file_lines[pos], body):
|
|
199
|
+
out.append(ln)
|
|
200
|
+
pos += 1
|
|
201
|
+
continue
|
|
202
|
+
# Look ahead — if the patch's context line shows up within `window`
|
|
203
|
+
# lines, splice the skipped file lines in as ' ' context.
|
|
204
|
+
found = -1
|
|
205
|
+
for k in range(1, window + 1):
|
|
206
|
+
if pos + k < len(file_lines) and _line_match(file_lines[pos + k], body):
|
|
207
|
+
found = pos + k
|
|
208
|
+
break
|
|
209
|
+
if found >= 0:
|
|
210
|
+
for k in range(pos, found):
|
|
211
|
+
out.append(" " + file_lines[k] + eol)
|
|
212
|
+
out.append(ln)
|
|
213
|
+
pos = found + 1
|
|
214
|
+
else:
|
|
215
|
+
out.append(ln)
|
|
216
|
+
return out, new_anchor
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _rewrite_hunk_header(header_line: str, new_anchor: int) -> str:
|
|
220
|
+
"""Replace the old_start in `@@ -N,M +X,Y @@ ...` with new_anchor.
|
|
221
|
+
|
|
222
|
+
Preserves the optional `,M` count and everything after the second `@@`.
|
|
223
|
+
"""
|
|
224
|
+
return re.sub(
|
|
225
|
+
r"^(@@ -)\d+(,?\d*)( \+\d+,?\d* @@.*)$",
|
|
226
|
+
lambda m: f"{m.group(1)}{new_anchor}{m.group(2)}{m.group(3)}",
|
|
227
|
+
header_line,
|
|
228
|
+
count=1,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def repair_patch_against_files(patch_text: str, repo_root) -> str:
|
|
233
|
+
"""Rewrite a patch in place against actual file content.
|
|
234
|
+
|
|
235
|
+
Reads each `+++ b/<path>` target from `repo_root` and walks every hunk's
|
|
236
|
+
context, splicing in any intermediate file lines the LLM dropped. Works
|
|
237
|
+
on patches whose paths are already stripped of any `repos/<name>/`
|
|
238
|
+
prefix (i.e. the per-repo sub-patch).
|
|
239
|
+
|
|
240
|
+
If a target file is missing or unreadable, the chunk is passed through
|
|
241
|
+
untouched. If a hunk's context can't be matched even with lookahead,
|
|
242
|
+
that hunk is also passed through — git apply will produce its normal
|
|
243
|
+
error. The rewriter is best-effort: it never makes a patch worse.
|
|
244
|
+
"""
|
|
245
|
+
if not patch_text:
|
|
246
|
+
return patch_text
|
|
247
|
+
repo_root = Path(repo_root)
|
|
248
|
+
|
|
249
|
+
chunks = re.split(r"^(?=diff --git )", patch_text, flags=re.MULTILINE)
|
|
250
|
+
out_parts: list[str] = []
|
|
251
|
+
for chunk in chunks:
|
|
252
|
+
if not chunk.startswith("diff --git "):
|
|
253
|
+
out_parts.append(chunk)
|
|
254
|
+
continue
|
|
255
|
+
m = _PATCH_FILE_HEADER_RE.search(chunk)
|
|
256
|
+
if not m:
|
|
257
|
+
out_parts.append(chunk)
|
|
258
|
+
continue
|
|
259
|
+
relpath = m.group(1).strip()
|
|
260
|
+
# Strip a/ b/ prefix tolerance: git uses a/b but some tools strip them
|
|
261
|
+
if relpath.startswith("b/"):
|
|
262
|
+
relpath = relpath[2:]
|
|
263
|
+
fpath = repo_root / relpath
|
|
264
|
+
if not fpath.is_file():
|
|
265
|
+
out_parts.append(chunk)
|
|
266
|
+
continue
|
|
267
|
+
try:
|
|
268
|
+
text = fpath.read_text(encoding="utf-8", errors="replace")
|
|
269
|
+
except Exception:
|
|
270
|
+
out_parts.append(chunk)
|
|
271
|
+
continue
|
|
272
|
+
file_lines = text.splitlines() # Strips line endings; matching is rstrip-tolerant
|
|
273
|
+
out_parts.append(_rewrite_chunk_hunks(chunk, file_lines))
|
|
274
|
+
return "".join(out_parts)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _rewrite_chunk_hunks(chunk: str, file_lines: list[str]) -> str:
|
|
278
|
+
"""Walk hunks within a single-file chunk and repair each body."""
|
|
279
|
+
lines = chunk.splitlines(keepends=True)
|
|
280
|
+
out: list[str] = []
|
|
281
|
+
i = 0
|
|
282
|
+
while i < len(lines):
|
|
283
|
+
line = lines[i]
|
|
284
|
+
bare = _strip_eol(line)
|
|
285
|
+
m = _HUNK_HEADER_RE.match(bare)
|
|
286
|
+
if not m:
|
|
287
|
+
out.append(line)
|
|
288
|
+
i += 1
|
|
289
|
+
continue
|
|
290
|
+
anchor = int(m.group("old_start"))
|
|
291
|
+
# Collect the body until the next hunk header or section break.
|
|
292
|
+
body: list[str] = []
|
|
293
|
+
j = i + 1
|
|
294
|
+
while j < len(lines):
|
|
295
|
+
nxt = lines[j]
|
|
296
|
+
nxt_bare = _strip_eol(nxt)
|
|
297
|
+
if _HUNK_HEADER_RE.match(nxt_bare):
|
|
298
|
+
break
|
|
299
|
+
if nxt_bare.startswith(("diff --git ", "--- ", "+++ ", "index ", "Binary ")):
|
|
300
|
+
break
|
|
301
|
+
body.append(nxt)
|
|
302
|
+
j += 1
|
|
303
|
+
new_body, new_anchor = _repair_hunk_body(file_lines, anchor, body)
|
|
304
|
+
new_header = _rewrite_hunk_header(bare, new_anchor)
|
|
305
|
+
# Preserve trailing newline of the original header line.
|
|
306
|
+
if line.endswith("\r\n"):
|
|
307
|
+
new_header += "\r\n"
|
|
308
|
+
elif line.endswith("\n"):
|
|
309
|
+
new_header += "\n"
|
|
310
|
+
out.append(new_header)
|
|
311
|
+
out.extend(new_body)
|
|
312
|
+
i = j
|
|
313
|
+
return "".join(out)
|
|
314
|
+
|
|
315
|
+
|
|
107
316
|
class MissingToolError(Exception):
|
|
108
317
|
"""Raised when a required build tool (e.g. mvn, gradle) is not installed."""
|
|
109
318
|
def __init__(self, tool: str):
|
|
@@ -268,12 +477,22 @@ def apply_and_commit(
|
|
|
268
477
|
logger.error("git pull failed for %s:\n%s", repo.repo_name, r.stderr)
|
|
269
478
|
return "failed", ""
|
|
270
479
|
|
|
271
|
-
r = _git(["apply", "--check", "--recount", "--ignore-whitespace", str(patch_path)], cwd=local_path, env=env)
|
|
480
|
+
r = _git(["apply", "--check", "--recount", "--ignore-whitespace", "-C1", str(patch_path)], cwd=local_path, env=env)
|
|
272
481
|
if r.returncode != 0:
|
|
273
|
-
|
|
274
|
-
|
|
482
|
+
try:
|
|
483
|
+
original = patch_path.read_text(encoding="utf-8", errors="replace")
|
|
484
|
+
repaired = repair_patch_against_files(original, local_path)
|
|
485
|
+
except Exception as _re:
|
|
486
|
+
logger.warning("patch repair failed for %s: %s", repo.repo_name, _re)
|
|
487
|
+
repaired = None
|
|
488
|
+
if repaired and repaired != original:
|
|
489
|
+
patch_path.write_text(repaired, encoding="utf-8")
|
|
490
|
+
r = _git(["apply", "--check", "--recount", "--ignore-whitespace", "-C1", str(patch_path)], cwd=local_path, env=env)
|
|
491
|
+
if r.returncode != 0:
|
|
492
|
+
logger.error("Patch dry-run failed for %s:\n%s", event.fingerprint, r.stderr)
|
|
493
|
+
return "failed", ""
|
|
275
494
|
|
|
276
|
-
r = _git(["apply", "--recount", "--ignore-whitespace", str(patch_path)], cwd=local_path, env=env)
|
|
495
|
+
r = _git(["apply", "--recount", "--ignore-whitespace", "-C1", str(patch_path)], cwd=local_path, env=env)
|
|
277
496
|
if r.returncode != 0:
|
|
278
497
|
logger.error("git apply failed for %s:\n%s", event.fingerprint, r.stderr)
|
|
279
498
|
return "failed", ""
|
|
@@ -382,10 +601,30 @@ def apply_and_commit_multi(
|
|
|
382
601
|
dry_run_failures.append(f"{name}: git pull failed: {r.stderr.strip()[:200]}")
|
|
383
602
|
continue
|
|
384
603
|
# Dry-run
|
|
385
|
-
r = _git(["apply", "--check", "--recount", "--ignore-whitespace", str(sub_path)],
|
|
604
|
+
r = _git(["apply", "--check", "--recount", "--ignore-whitespace", "-C1", str(sub_path)],
|
|
386
605
|
cwd=repo.local_path, env=env)
|
|
387
606
|
if r.returncode != 0:
|
|
388
|
-
|
|
607
|
+
# Try repairing the patch by splicing in any intermediate file lines
|
|
608
|
+
# the LLM dropped from hunk context. Only retry if the rewrite
|
|
609
|
+
# actually changed the patch — otherwise we'd be looping.
|
|
610
|
+
try:
|
|
611
|
+
original = sub_path.read_text(encoding="utf-8", errors="replace")
|
|
612
|
+
repaired = repair_patch_against_files(original, repo.local_path)
|
|
613
|
+
except Exception as _re:
|
|
614
|
+
logger.warning("patch repair failed for %s: %s", name, _re)
|
|
615
|
+
repaired = None
|
|
616
|
+
if repaired and repaired != original:
|
|
617
|
+
sub_path.write_text(repaired, encoding="utf-8")
|
|
618
|
+
r = _git(
|
|
619
|
+
["apply", "--check", "--recount", "--ignore-whitespace", "-C1", str(sub_path)],
|
|
620
|
+
cwd=repo.local_path, env=env,
|
|
621
|
+
)
|
|
622
|
+
if r.returncode == 0:
|
|
623
|
+
logger.info("Patch repair succeeded for %s — spliced missing context", name)
|
|
624
|
+
else:
|
|
625
|
+
dry_run_failures.append(f"{name}: dry-run failed: {r.stderr.strip()[:200]}")
|
|
626
|
+
else:
|
|
627
|
+
dry_run_failures.append(f"{name}: dry-run failed: {r.stderr.strip()[:200]}")
|
|
389
628
|
|
|
390
629
|
if dry_run_failures:
|
|
391
630
|
reason = "; ".join(dry_run_failures)
|
|
@@ -414,7 +653,7 @@ def apply_and_commit_multi(
|
|
|
414
653
|
"reason": "", "sub_patch_path": sub_path,
|
|
415
654
|
}
|
|
416
655
|
|
|
417
|
-
r = _git(["apply", "--recount", "--ignore-whitespace", str(sub_path)],
|
|
656
|
+
r = _git(["apply", "--recount", "--ignore-whitespace", "-C1", str(sub_path)],
|
|
418
657
|
cwd=repo.local_path, env=env)
|
|
419
658
|
if r.returncode != 0:
|
|
420
659
|
entry["reason"] = f"apply failed: {r.stderr.strip()[:200]}"
|
package/python/sentinel/main.py
CHANGED
|
@@ -563,7 +563,11 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
563
563
|
return
|
|
564
564
|
|
|
565
565
|
if store.fix_attempted_recently(event.fingerprint, hours=24):
|
|
566
|
-
logger.
|
|
566
|
+
logger.info(
|
|
567
|
+
"Issue %s skipped — fingerprint %s attempted in last 24h "
|
|
568
|
+
"(use Boss `retry_issue` to clear the prior row and re-attempt)",
|
|
569
|
+
event.source, event.fingerprint,
|
|
570
|
+
)
|
|
567
571
|
mark_done(event.issue_file)
|
|
568
572
|
return
|
|
569
573
|
|
|
@@ -2632,6 +2632,27 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
2632
2632
|
except Exception as _e:
|
|
2633
2633
|
logger.debug("retry_issue: state_store guard failed (non-fatal): %s", _e)
|
|
2634
2634
|
|
|
2635
|
+
# Clear the 24h dedupe so _handle_issue actually re-runs the fix.
|
|
2636
|
+
# fix_attempted_recently() ignores status='skipped', so flipping any
|
|
2637
|
+
# recent 'failed' row for this fingerprint is enough.
|
|
2638
|
+
try:
|
|
2639
|
+
with store._conn() as _c:
|
|
2640
|
+
_n = _c.execute(
|
|
2641
|
+
"UPDATE fixes SET status='skipped' "
|
|
2642
|
+
"WHERE fingerprint=? AND status='failed' "
|
|
2643
|
+
"AND timestamp >= datetime('now', '-24 hours')",
|
|
2644
|
+
(_fp,),
|
|
2645
|
+
).rowcount
|
|
2646
|
+
_c.commit()
|
|
2647
|
+
if _n:
|
|
2648
|
+
logger.info(
|
|
2649
|
+
"Boss retry_issue: cleared %d prior failed row(s) for fingerprint %s "
|
|
2650
|
+
"so the retry won't be deduped",
|
|
2651
|
+
_n, _fp,
|
|
2652
|
+
)
|
|
2653
|
+
except Exception as _e:
|
|
2654
|
+
logger.debug("retry_issue: clearing prior failed rows failed (non-fatal): %s", _e)
|
|
2655
|
+
|
|
2635
2656
|
# Re-submit as a fresh issue file
|
|
2636
2657
|
issues_dir = project_dir / "issues"
|
|
2637
2658
|
issues_dir.mkdir(exist_ok=True)
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""
|
|
2
|
+
test_patch_repair.py — Unit tests for repair_patch_against_files().
|
|
3
|
+
|
|
4
|
+
LLM-generated diffs sometimes drop blank/whitespace-only lines from a hunk's
|
|
5
|
+
trailing context — the most common case is a Java/Kotlin source file where
|
|
6
|
+
`send()`'s closing `}` is on line N, and the class-closing `}` is on line N+4
|
|
7
|
+
because there are stray blank lines between them. Claude's patch jumps from
|
|
8
|
+
` }` (1-tab method close) directly to `}` (column-0 class close), without
|
|
9
|
+
the intermediate blanks. `git apply --check --recount --ignore-whitespace`
|
|
10
|
+
rejects that because context must match the file contiguously.
|
|
11
|
+
|
|
12
|
+
The rewriter reads the actual file from disk, finds the missing intermediate
|
|
13
|
+
lines, and splices them in as ' ' context. After repair, `git apply --check
|
|
14
|
+
--recount` succeeds.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import pytest
|
|
23
|
+
|
|
24
|
+
from sentinel.git_manager import (
|
|
25
|
+
_line_match,
|
|
26
|
+
_repair_hunk_body,
|
|
27
|
+
_strip_eol,
|
|
28
|
+
repair_patch_against_files,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── pure-function tests for the low-level helpers ────────────────────────────
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_line_match_strips_cr_and_trailing_whitespace():
|
|
36
|
+
assert _line_match("hello", "hello")
|
|
37
|
+
assert _line_match("hello ", "hello")
|
|
38
|
+
assert _line_match("hello\r", "hello")
|
|
39
|
+
assert _line_match("\t}\r", "\t}")
|
|
40
|
+
assert not _line_match("hello", "world")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_strip_eol_handles_crlf_lf_cr_and_none():
|
|
44
|
+
assert _strip_eol("foo\r\n") == "foo"
|
|
45
|
+
assert _strip_eol("foo\n") == "foo"
|
|
46
|
+
assert _strip_eol("foo\r") == "foo"
|
|
47
|
+
assert _strip_eol("foo") == "foo"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_repair_hunk_body_splices_missing_blank_lines():
|
|
51
|
+
"""The d59ac840 case: hunk goes from method-close to class-close, skipping
|
|
52
|
+
3 blank/whitespace lines that exist in the file."""
|
|
53
|
+
file_lines = [
|
|
54
|
+
"public class Foo {", # 1
|
|
55
|
+
"\tvoid send() {", # 2
|
|
56
|
+
"\t\tdoStuff();", # 3
|
|
57
|
+
"\t}", # 4 ← method close
|
|
58
|
+
"\t", # 5 ← stray tab
|
|
59
|
+
"", # 6 ← blank
|
|
60
|
+
"", # 7 ← blank
|
|
61
|
+
"}", # 8 ← class close
|
|
62
|
+
]
|
|
63
|
+
body = [
|
|
64
|
+
" \tvoid send() {\n",
|
|
65
|
+
" \t\tdoStuff();\n",
|
|
66
|
+
" \t}\n",
|
|
67
|
+
" }\n", # ← context jumps from L4 method-close to L8 class-close
|
|
68
|
+
]
|
|
69
|
+
new_body, new_anchor = _repair_hunk_body(file_lines, anchor=2, body_lines=body)
|
|
70
|
+
assert new_anchor == 2
|
|
71
|
+
# The trailing context " }" must be preceded by 3 spliced ' ' context lines
|
|
72
|
+
bodies = [ln for ln in new_body]
|
|
73
|
+
assert bodies[-1] == " }\n"
|
|
74
|
+
assert bodies[-2] == " \n"
|
|
75
|
+
assert bodies[-3] == " \n"
|
|
76
|
+
assert bodies[-4] == " \t\n"
|
|
77
|
+
# Earlier context unchanged
|
|
78
|
+
assert " \tvoid send() {\n" in bodies
|
|
79
|
+
assert " \t}\n" in bodies
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_repair_hunk_body_preserves_crlf_eol_when_splicing():
|
|
83
|
+
file_lines = ["a", "", "", "b"]
|
|
84
|
+
body = [
|
|
85
|
+
" a\r\n",
|
|
86
|
+
" b\r\n", # ← skips the 2 blank lines
|
|
87
|
+
]
|
|
88
|
+
new_body, _ = _repair_hunk_body(file_lines, anchor=1, body_lines=body)
|
|
89
|
+
# Spliced lines should also use CRLF
|
|
90
|
+
assert new_body[1] == " \r\n"
|
|
91
|
+
assert new_body[2] == " \r\n"
|
|
92
|
+
assert new_body[3] == " b\r\n"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_repair_hunk_body_calibrates_offset_anchor():
|
|
96
|
+
"""If the LLM gets the hunk's start line number wrong by a few lines,
|
|
97
|
+
the calibration step finds the real start within the search window."""
|
|
98
|
+
file_lines = [
|
|
99
|
+
"header", # 1
|
|
100
|
+
"", # 2
|
|
101
|
+
"", # 3
|
|
102
|
+
"void send() {", # 4 ← real start
|
|
103
|
+
" body();", # 5
|
|
104
|
+
"}", # 6
|
|
105
|
+
]
|
|
106
|
+
body = [
|
|
107
|
+
" void send() {\n",
|
|
108
|
+
" }\n", # skips body() — different test case
|
|
109
|
+
]
|
|
110
|
+
# LLM said the hunk starts at line 1 (off by 3)
|
|
111
|
+
new_body, new_anchor = _repair_hunk_body(file_lines, anchor=1, body_lines=body)
|
|
112
|
+
assert new_anchor == 4 # calibrated to the real start
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_repair_hunk_body_leaves_unmatchable_lines_alone():
|
|
116
|
+
"""If a context line genuinely doesn't appear in the file, the rewriter
|
|
117
|
+
passes it through and lets git apply produce its real error."""
|
|
118
|
+
file_lines = ["a", "b", "c"]
|
|
119
|
+
body = [
|
|
120
|
+
" a\n",
|
|
121
|
+
" ZZZ-NOT-IN-FILE\n",
|
|
122
|
+
" c\n",
|
|
123
|
+
]
|
|
124
|
+
new_body, _ = _repair_hunk_body(file_lines, anchor=1, body_lines=body)
|
|
125
|
+
# The unmatchable line is preserved, no infinite splicing
|
|
126
|
+
assert " ZZZ-NOT-IN-FILE\n" in new_body
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ── integration tests against real `git apply --check` ───────────────────────
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _git_available() -> bool:
|
|
133
|
+
return shutil.which("git") is not None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@pytest.mark.skipif(not _git_available(), reason="git binary not available")
|
|
137
|
+
def test_repair_makes_d59ac840_patch_apply(tmp_path: Path):
|
|
138
|
+
"""Reproduce the production failure: a Java file with stray blank lines
|
|
139
|
+
between method-close and class-close, plus a Claude-style patch that
|
|
140
|
+
drops them. After repair, `git apply --check` must succeed."""
|
|
141
|
+
repo = tmp_path / "repo"
|
|
142
|
+
repo.mkdir()
|
|
143
|
+
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
|
144
|
+
subprocess.run(["git", "config", "user.email", "test@test"], cwd=repo, check=True)
|
|
145
|
+
subprocess.run(["git", "config", "user.name", "test"], cwd=repo, check=True)
|
|
146
|
+
|
|
147
|
+
src = repo / "src" / "Foo.java"
|
|
148
|
+
src.parent.mkdir(parents=True)
|
|
149
|
+
# Real-world layout: tab + 3 blank lines before class close
|
|
150
|
+
src.write_text(
|
|
151
|
+
"public class Foo {\n"
|
|
152
|
+
"\tvoid send() {\n"
|
|
153
|
+
"\t\tlog.warn(\"old\");\n"
|
|
154
|
+
"\t}\n"
|
|
155
|
+
"\t\n"
|
|
156
|
+
"\n"
|
|
157
|
+
"\n"
|
|
158
|
+
"}\n",
|
|
159
|
+
encoding="utf-8",
|
|
160
|
+
)
|
|
161
|
+
subprocess.run(["git", "add", "."], cwd=repo, check=True)
|
|
162
|
+
subprocess.run(["git", "commit", "-qm", "init"], cwd=repo, check=True)
|
|
163
|
+
|
|
164
|
+
# Claude-style patch: trailing context skips the 3 intermediate lines.
|
|
165
|
+
patch = (
|
|
166
|
+
"diff --git a/src/Foo.java b/src/Foo.java\n"
|
|
167
|
+
"--- a/src/Foo.java\n"
|
|
168
|
+
"+++ b/src/Foo.java\n"
|
|
169
|
+
"@@ -2,4 +2,5 @@\n"
|
|
170
|
+
" \tvoid send() {\n"
|
|
171
|
+
"-\t\tlog.warn(\"old\");\n"
|
|
172
|
+
"+\t\tlog.error(\"new\");\n"
|
|
173
|
+
"+\t\tnotify();\n"
|
|
174
|
+
" \t}\n"
|
|
175
|
+
" }\n"
|
|
176
|
+
)
|
|
177
|
+
patch_path = tmp_path / "claude.diff"
|
|
178
|
+
patch_path.write_text(patch, encoding="utf-8")
|
|
179
|
+
|
|
180
|
+
# Sanity: vanilla git apply --check should fail
|
|
181
|
+
r = subprocess.run(
|
|
182
|
+
["git", "apply", "--check", "--recount", "--ignore-whitespace", str(patch_path)],
|
|
183
|
+
cwd=repo, capture_output=True, text=True,
|
|
184
|
+
)
|
|
185
|
+
assert r.returncode != 0, "test setup wrong — patch was supposed to fail vanilla apply"
|
|
186
|
+
|
|
187
|
+
# Repair and retry
|
|
188
|
+
repaired = repair_patch_against_files(patch, repo)
|
|
189
|
+
assert repaired != patch
|
|
190
|
+
repaired_path = tmp_path / "repaired.diff"
|
|
191
|
+
repaired_path.write_text(repaired, encoding="utf-8")
|
|
192
|
+
r = subprocess.run(
|
|
193
|
+
["git", "apply", "--check", "--recount", "--ignore-whitespace", str(repaired_path)],
|
|
194
|
+
cwd=repo, capture_output=True, text=True,
|
|
195
|
+
)
|
|
196
|
+
assert r.returncode == 0, f"repaired patch should apply but got: {r.stderr}"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@pytest.mark.skipif(not _git_available(), reason="git binary not available")
|
|
200
|
+
def test_repair_is_noop_when_patch_already_matches(tmp_path: Path):
|
|
201
|
+
"""If a patch has the right context, the rewriter must return it
|
|
202
|
+
unchanged. We don't want to introduce gratuitous diffs."""
|
|
203
|
+
repo = tmp_path / "repo"
|
|
204
|
+
repo.mkdir()
|
|
205
|
+
src = repo / "Foo.java"
|
|
206
|
+
src.write_text("a\nb\nc\n", encoding="utf-8")
|
|
207
|
+
|
|
208
|
+
patch = (
|
|
209
|
+
"diff --git a/Foo.java b/Foo.java\n"
|
|
210
|
+
"--- a/Foo.java\n"
|
|
211
|
+
"+++ b/Foo.java\n"
|
|
212
|
+
"@@ -1,3 +1,3 @@\n"
|
|
213
|
+
" a\n"
|
|
214
|
+
"-b\n"
|
|
215
|
+
"+B\n"
|
|
216
|
+
" c\n"
|
|
217
|
+
)
|
|
218
|
+
repaired = repair_patch_against_files(patch, repo)
|
|
219
|
+
# Anchor recalibration may rewrite the header but body should be identical
|
|
220
|
+
assert " a\n-b\n+B\n c\n" in repaired
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_repair_passes_through_when_target_file_missing(tmp_path: Path):
|
|
224
|
+
"""If `+++ b/<path>` points to a file that doesn't exist, the rewriter
|
|
225
|
+
must leave the chunk alone — git apply will produce its own error."""
|
|
226
|
+
patch = (
|
|
227
|
+
"diff --git a/missing.java b/missing.java\n"
|
|
228
|
+
"--- a/missing.java\n"
|
|
229
|
+
"+++ b/missing.java\n"
|
|
230
|
+
"@@ -1,1 +1,1 @@\n"
|
|
231
|
+
"-x\n"
|
|
232
|
+
"+y\n"
|
|
233
|
+
)
|
|
234
|
+
repaired = repair_patch_against_files(patch, tmp_path)
|
|
235
|
+
assert repaired == patch
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_repair_handles_empty_input():
|
|
239
|
+
assert repair_patch_against_files("", "/anywhere") == ""
|
|
240
|
+
assert repair_patch_against_files(None, "/anywhere") in (None, "") # type: ignore[arg-type]
|
|
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
|
|
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
|