@misterhuydo/sentinel 1.4.86 → 1.4.88
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/sentinel/fix_engine.py +103 -15
- package/python/sentinel/main.py +29 -5
- package/python/sentinel/notify.py +17 -3
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-03-27T05:
|
|
3
|
-
"checkpoint_at": "2026-03-27T05:
|
|
2
|
+
"message": "Auto-checkpoint at 2026-03-27T05:16:34.970Z",
|
|
3
|
+
"checkpoint_at": "2026-03-27T05:16:34.971Z",
|
|
4
4
|
"active_files": [],
|
|
5
5
|
"notes": [],
|
|
6
6
|
"mtime_snapshot": {}
|
package/package.json
CHANGED
|
@@ -191,32 +191,118 @@ def _claude_cmd(bin_path: str, prompt: str) -> list[str]:
|
|
|
191
191
|
return [bin_path, "--bare", "--print", prompt]
|
|
192
192
|
|
|
193
193
|
|
|
194
|
+
# ── Claude output → human-readable progress ───────────────────────────────────
|
|
195
|
+
|
|
196
|
+
# Tool-use lines emitted by Claude Code look like: "⏺ ToolName(args...)"
|
|
197
|
+
_TOOL_USE_RE = re.compile(r'^[⏺⎆●✦]\s*(\w+)\s*\((.{0,120})', re.UNICODE)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _progress_from_line(line: str) -> str | None:
|
|
201
|
+
"""
|
|
202
|
+
Convert a raw Claude Code output line into a brief Slack progress message.
|
|
203
|
+
Returns None for lines that are too noisy to show.
|
|
204
|
+
"""
|
|
205
|
+
stripped = line.strip()
|
|
206
|
+
m = _TOOL_USE_RE.match(stripped)
|
|
207
|
+
if not m:
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
tool = m.group(1)
|
|
211
|
+
args = m.group(2).rstrip(')')
|
|
212
|
+
|
|
213
|
+
if tool == "Bash":
|
|
214
|
+
cmd = args.strip()
|
|
215
|
+
if re.search(r'\bmvn\b.*\btest\b|\bgradle\b.*\btest\b', cmd):
|
|
216
|
+
return ":test_tube: Running tests..."
|
|
217
|
+
if re.search(r'\byarn\b.*\btest\b|\bnpm\b.*\btest\b', cmd):
|
|
218
|
+
return ":test_tube: Running tests..."
|
|
219
|
+
if re.search(r'\byarn\b.*\binstall\b|\bnpm\b.*\binstall\b', cmd):
|
|
220
|
+
return ":package: Installing dependencies..."
|
|
221
|
+
if re.search(r'\byarn\b\s*$|\byarn\b\s+build', cmd):
|
|
222
|
+
return ":package: Running yarn build..."
|
|
223
|
+
if re.search(r'\bmvn\b', cmd):
|
|
224
|
+
return ":hammer: Running Maven..."
|
|
225
|
+
if re.search(r'\bgradle\b|gradlew', cmd):
|
|
226
|
+
return ":hammer: Running Gradle..."
|
|
227
|
+
if re.search(r'\bgit\b.*\bcommit\b', cmd):
|
|
228
|
+
return ":floppy_disk: Committing changes..."
|
|
229
|
+
if re.search(r'\bgit\b.*\bpush\b', cmd):
|
|
230
|
+
return ":arrow_up: Pushing to remote..."
|
|
231
|
+
if re.search(r'\bnpm\b\s+install\b|\bnpm\b\s+ci\b', cmd):
|
|
232
|
+
return ":package: Installing npm packages..."
|
|
233
|
+
return None # other bash — skip
|
|
234
|
+
|
|
235
|
+
if tool in ("Edit", "MultiEdit"):
|
|
236
|
+
fname = args.split(",")[0].strip().split("/")[-1]
|
|
237
|
+
if fname:
|
|
238
|
+
return f":pencil2: Patching `{fname}`"
|
|
239
|
+
return ":pencil2: Editing files..."
|
|
240
|
+
|
|
241
|
+
if tool == "Write":
|
|
242
|
+
fname = args.split(",")[0].strip().split("/")[-1]
|
|
243
|
+
if fname:
|
|
244
|
+
return f":pencil2: Writing `{fname}`"
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
# Read, Glob, Grep, Agent, WebFetch → suppress
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
194
251
|
def _run_claude_attempt(
|
|
195
252
|
bin_path: str,
|
|
196
253
|
prompt: str,
|
|
197
254
|
env: dict,
|
|
198
255
|
cwd: str | None = None,
|
|
199
256
|
claude_log_path: Path | None = None,
|
|
257
|
+
on_progress=None,
|
|
200
258
|
) -> tuple[str, bool]:
|
|
201
259
|
"""
|
|
202
260
|
Run claude CLI with the given env. Returns (output, timed_out).
|
|
203
261
|
Raises FileNotFoundError if binary is missing.
|
|
204
|
-
If
|
|
262
|
+
If on_progress is given, calls on_progress(msg) for meaningful output lines
|
|
263
|
+
(deduped — same message not repeated consecutively).
|
|
205
264
|
"""
|
|
265
|
+
import threading as _threading
|
|
266
|
+
|
|
267
|
+
proc = subprocess.Popen(
|
|
268
|
+
_claude_cmd(bin_path, prompt),
|
|
269
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
270
|
+
text=True, env=env, cwd=cwd or None,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
timed_out_flag = [False]
|
|
274
|
+
def _kill():
|
|
275
|
+
timed_out_flag[0] = True
|
|
276
|
+
try:
|
|
277
|
+
proc.kill()
|
|
278
|
+
except Exception:
|
|
279
|
+
pass
|
|
280
|
+
timer = _threading.Timer(SUBPROCESS_TIMEOUT, _kill)
|
|
281
|
+
timer.start()
|
|
282
|
+
|
|
283
|
+
lines = []
|
|
284
|
+
last_progress = [None]
|
|
206
285
|
try:
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
286
|
+
for line in proc.stdout:
|
|
287
|
+
lines.append(line)
|
|
288
|
+
if on_progress:
|
|
289
|
+
msg = _progress_from_line(line)
|
|
290
|
+
if msg and msg != last_progress[0]:
|
|
291
|
+
last_progress[0] = msg
|
|
292
|
+
try:
|
|
293
|
+
on_progress(msg)
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
proc.wait()
|
|
297
|
+
except Exception:
|
|
298
|
+
pass
|
|
299
|
+
finally:
|
|
300
|
+
timer.cancel()
|
|
301
|
+
|
|
302
|
+
output = "".join(lines)
|
|
303
|
+
if claude_log_path:
|
|
304
|
+
_write_claude_log(claude_log_path, prompt, output, timed_out=timed_out_flag[0])
|
|
305
|
+
return output, timed_out_flag[0]
|
|
220
306
|
|
|
221
307
|
|
|
222
308
|
def _write_claude_log(log_path: Path, prompt: str, output: str, timed_out: bool) -> None:
|
|
@@ -241,6 +327,7 @@ def generate_fix(
|
|
|
241
327
|
cfg: SentinelConfig,
|
|
242
328
|
patches_dir: Path,
|
|
243
329
|
store=None,
|
|
330
|
+
on_progress=None,
|
|
244
331
|
) -> tuple[str, Path | None, str]:
|
|
245
332
|
"""
|
|
246
333
|
Generate a fix for the given error event.
|
|
@@ -310,7 +397,8 @@ def generate_fix(
|
|
|
310
397
|
continue
|
|
311
398
|
logger.info("fix_engine: trying %s for %s", label, event.fingerprint)
|
|
312
399
|
output, timed_out = _run_claude_attempt(
|
|
313
|
-
cfg.claude_code_bin, prompt, env, cwd=repo.local_path,
|
|
400
|
+
cfg.claude_code_bin, prompt, env, cwd=repo.local_path,
|
|
401
|
+
claude_log_path=claude_log_path, on_progress=on_progress,
|
|
314
402
|
)
|
|
315
403
|
if timed_out:
|
|
316
404
|
logger.error("Claude Code timed out for %s", event.fingerprint)
|
package/python/sentinel/main.py
CHANGED
|
@@ -316,8 +316,8 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
316
316
|
)
|
|
317
317
|
return # Leave the file so admin can add the header
|
|
318
318
|
|
|
319
|
-
# Post "working on" to channel
|
|
320
|
-
from .notify import slack_alert as _slack_alert
|
|
319
|
+
# Post "working on" to channel and capture thread_ts for progress replies
|
|
320
|
+
from .notify import slack_alert as _slack_alert, slack_thread_reply as _slack_reply
|
|
321
321
|
_submitter = getattr(event, "submitter_user_id", "")
|
|
322
322
|
_started_msg = (
|
|
323
323
|
f":hammer: Working on *<@{_submitter}>*'s request — *{repo.repo_name}*\n"
|
|
@@ -325,15 +325,21 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
325
325
|
) if _submitter else (
|
|
326
326
|
f":hammer: Working on *{repo.repo_name}*\n_{event.message[:120]}_"
|
|
327
327
|
)
|
|
328
|
-
_slack_alert(sentinel.slack_bot_token, sentinel.slack_channel, _started_msg)
|
|
328
|
+
_thread_ts = _slack_alert(sentinel.slack_bot_token, sentinel.slack_channel, _started_msg)
|
|
329
|
+
|
|
330
|
+
def _progress(msg: str) -> None:
|
|
331
|
+
"""Post a threaded reply under the 'Working on' message."""
|
|
332
|
+
_slack_reply(sentinel.slack_bot_token, sentinel.slack_channel, _thread_ts, msg)
|
|
329
333
|
|
|
330
334
|
try:
|
|
331
335
|
patches_dir = Path(sentinel.workspace_dir).resolve() / "patches"
|
|
332
336
|
_loop = asyncio.get_event_loop()
|
|
333
337
|
|
|
334
|
-
# Run blocking subprocess work in thread executor so Boss stays responsive
|
|
338
|
+
# Run blocking subprocess work in thread executor so Boss stays responsive.
|
|
339
|
+
# _progress is thread-safe (HTTP POST) so pass it as streaming callback.
|
|
340
|
+
_progress(":brain: Analyzing with Claude Code...")
|
|
335
341
|
status, patch_path, marker = await _loop.run_in_executor(
|
|
336
|
-
None, generate_fix, event, repo, sentinel, patches_dir, store
|
|
342
|
+
None, generate_fix, event, repo, sentinel, patches_dir, store, _progress
|
|
337
343
|
)
|
|
338
344
|
|
|
339
345
|
submitter_uid = getattr(event, "submitter_user_id", "")
|
|
@@ -342,6 +348,7 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
342
348
|
store.record_fix(event.fingerprint, "skipped" if status in ("skip", "needs_human") else "failed",
|
|
343
349
|
repo_name=repo.repo_name)
|
|
344
350
|
reason_text = marker if status == "needs_human" else f"Claude Code returned {status.upper()}"
|
|
351
|
+
_progress(f":x: Could not generate a safe fix — {reason_text[:120]}")
|
|
345
352
|
notify_fix_blocked(sentinel, event.source, event.message,
|
|
346
353
|
reason=reason_text, repo_name=repo.repo_name,
|
|
347
354
|
submitter_user_id=submitter_uid)
|
|
@@ -349,11 +356,13 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
349
356
|
return {"submitter": submitter_uid, "repo_name": repo.repo_name,
|
|
350
357
|
"status": "blocked", "summary": reason_text[:120], "pr_url": ""}
|
|
351
358
|
|
|
359
|
+
_progress(f":mag: Patch generated — running tests (`{repo.repo_name}`)...")
|
|
352
360
|
commit_status, commit_hash = await _loop.run_in_executor(
|
|
353
361
|
None, apply_and_commit, event, patch_path, repo, sentinel
|
|
354
362
|
)
|
|
355
363
|
if commit_status != "committed":
|
|
356
364
|
store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
|
|
365
|
+
_progress(":x: Tests failed or patch couldn't apply — needs human review")
|
|
357
366
|
notify_fix_blocked(sentinel, event.source, event.message,
|
|
358
367
|
reason="Patch was generated but commit/tests failed",
|
|
359
368
|
repo_name=repo.repo_name,
|
|
@@ -362,6 +371,7 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
362
371
|
return {"submitter": submitter_uid, "repo_name": repo.repo_name,
|
|
363
372
|
"status": "blocked", "summary": "Commit/tests failed", "pr_url": ""}
|
|
364
373
|
|
|
374
|
+
_progress(f":white_check_mark: Tests passed — committed `{commit_hash[:8]}`, pushing to `{repo.branch}`...")
|
|
365
375
|
branch, pr_url = publish(event, repo, sentinel, commit_hash)
|
|
366
376
|
store.record_fix(
|
|
367
377
|
event.fingerprint,
|
|
@@ -387,6 +397,8 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
387
397
|
"auto_publish": repo.auto_publish,
|
|
388
398
|
"files_changed": [],
|
|
389
399
|
})
|
|
400
|
+
if pr_url:
|
|
401
|
+
_progress(f":arrow_right: <{pr_url}|PR opened> — awaiting review")
|
|
390
402
|
notify_fix_applied(sentinel, event.source, event.message,
|
|
391
403
|
repo_name=repo.repo_name, branch=branch, pr_url=pr_url,
|
|
392
404
|
submitter_user_id=submitter_uid)
|
|
@@ -394,6 +406,8 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
394
406
|
|
|
395
407
|
if repo.auto_publish:
|
|
396
408
|
ok = cicd_trigger(repo, store, event.fingerprint)
|
|
409
|
+
if ok:
|
|
410
|
+
_progress(f":rocket: Release triggered via {repo.cicd_type}")
|
|
397
411
|
if ok and repo.cicd_type.lower() in ("jenkins_release", "jenkins-release"):
|
|
398
412
|
_run_cascade(repo, sentinel, cfg_loader)
|
|
399
413
|
|
|
@@ -403,15 +417,18 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
403
417
|
except MissingToolError as e:
|
|
404
418
|
logger.warning("Missing tool for %s: %s", event.source, e)
|
|
405
419
|
submitter_uid = getattr(event, "submitter_user_id", "")
|
|
420
|
+
_progress(f":wrench: `{e.tool}` not found — auto-installing...")
|
|
406
421
|
installed = await _loop.run_in_executor(
|
|
407
422
|
None, _auto_install_if_safe, e.tool, repo.local_path, sentinel, repo.repo_name, event.source
|
|
408
423
|
)
|
|
409
424
|
if not installed:
|
|
425
|
+
_progress(f":x: `{e.tool}` is not a known safe tool — manual install required")
|
|
410
426
|
notify_missing_tool(sentinel, e.tool, repo.repo_name, event.source, submitter_uid)
|
|
411
427
|
store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
|
|
412
428
|
mark_done(event.issue_file)
|
|
413
429
|
return {"submitter": submitter_uid, "repo_name": repo.repo_name,
|
|
414
430
|
"status": "blocked", "summary": f"Missing tool: {e.tool}", "pr_url": ""}
|
|
431
|
+
_progress(f":white_check_mark: `{e.tool}` installed — retrying tests...")
|
|
415
432
|
# Tool installed — retry apply_and_commit once
|
|
416
433
|
try:
|
|
417
434
|
commit_status, commit_hash = await _loop.run_in_executor(
|
|
@@ -419,6 +436,7 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
419
436
|
)
|
|
420
437
|
except MissingToolError as e2:
|
|
421
438
|
logger.error("Still missing tool after auto-install: %s", e2)
|
|
439
|
+
_progress(f":x: Still missing `{e2.tool}` after install — manual fix needed")
|
|
422
440
|
notify_missing_tool(sentinel, e2.tool, repo.repo_name, event.source, submitter_uid)
|
|
423
441
|
store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
|
|
424
442
|
mark_done(event.issue_file)
|
|
@@ -426,12 +444,14 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
426
444
|
"status": "blocked", "summary": f"Missing tool: {e2.tool}", "pr_url": ""}
|
|
427
445
|
if commit_status != "committed":
|
|
428
446
|
store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
|
|
447
|
+
_progress(":x: Tests still failing after tool install — needs human review")
|
|
429
448
|
notify_fix_blocked(sentinel, event.source, event.message,
|
|
430
449
|
reason="Patch was generated but commit/tests failed after tool install",
|
|
431
450
|
repo_name=repo.repo_name, submitter_user_id=submitter_uid)
|
|
432
451
|
mark_done(event.issue_file)
|
|
433
452
|
return {"submitter": submitter_uid, "repo_name": repo.repo_name,
|
|
434
453
|
"status": "blocked", "summary": "Commit/tests failed after tool install", "pr_url": ""}
|
|
454
|
+
_progress(f":white_check_mark: Tests passed — committed `{commit_hash[:8]}`, pushing to `{repo.branch}`...")
|
|
435
455
|
branch, pr_url = publish(event, repo, sentinel, commit_hash)
|
|
436
456
|
store.record_fix(
|
|
437
457
|
event.fingerprint,
|
|
@@ -447,12 +467,16 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
|
|
|
447
467
|
"branch": branch, "pr_url": pr_url,
|
|
448
468
|
"auto_publish": repo.auto_publish, "files_changed": [],
|
|
449
469
|
})
|
|
470
|
+
if pr_url:
|
|
471
|
+
_progress(f":arrow_right: <{pr_url}|PR opened> — awaiting review")
|
|
450
472
|
notify_fix_applied(sentinel, event.source, event.message,
|
|
451
473
|
repo_name=repo.repo_name, branch=branch, pr_url=pr_url,
|
|
452
474
|
submitter_user_id=submitter_uid)
|
|
453
475
|
mark_done(event.issue_file)
|
|
454
476
|
if repo.auto_publish:
|
|
455
477
|
ok = cicd_trigger(repo, store, event.fingerprint)
|
|
478
|
+
if ok:
|
|
479
|
+
_progress(f":rocket: Release triggered via {repo.cicd_type}")
|
|
456
480
|
if ok and repo.cicd_type.lower() in ("jenkins_release", "jenkins-release"):
|
|
457
481
|
_run_cascade(repo, sentinel, cfg_loader)
|
|
458
482
|
return {"submitter": submitter_uid, "repo_name": repo.repo_name,
|
|
@@ -127,29 +127,43 @@ def rate_limit_message(source: str, raw: str) -> str:
|
|
|
127
127
|
|
|
128
128
|
# ── Alert dispatcher ──────────────────────────────────────────────────────────
|
|
129
129
|
|
|
130
|
-
def slack_alert(bot_token: str, channel: str, text: str) ->
|
|
130
|
+
def slack_alert(bot_token: str, channel: str, text: str, thread_ts: str = "") -> str:
|
|
131
131
|
"""
|
|
132
132
|
Post a plain-text alert to a Slack channel.
|
|
133
133
|
Best-effort: logs on failure, never raises.
|
|
134
|
+
Returns the message timestamp (ts) on success, "" on failure.
|
|
135
|
+
Pass thread_ts to reply inside an existing thread.
|
|
134
136
|
"""
|
|
135
137
|
if not bot_token or not channel:
|
|
136
138
|
logger.debug("slack_alert: no token/channel configured — logging only: %s", text[:120])
|
|
137
|
-
return
|
|
139
|
+
return ""
|
|
138
140
|
try:
|
|
141
|
+
payload: dict = {"channel": channel, "text": text}
|
|
142
|
+
if thread_ts:
|
|
143
|
+
payload["thread_ts"] = thread_ts
|
|
139
144
|
resp = requests.post(
|
|
140
145
|
"https://slack.com/api/chat.postMessage",
|
|
141
146
|
headers={
|
|
142
147
|
"Authorization": f"Bearer {bot_token}",
|
|
143
148
|
"Content-Type": "application/json",
|
|
144
149
|
},
|
|
145
|
-
json=
|
|
150
|
+
json=payload,
|
|
146
151
|
timeout=10,
|
|
147
152
|
)
|
|
148
153
|
data = resp.json()
|
|
149
154
|
if not data.get("ok"):
|
|
150
155
|
logger.warning("slack_alert: Slack API error: %s", data.get("error"))
|
|
156
|
+
return ""
|
|
157
|
+
return data.get("ts", "")
|
|
151
158
|
except Exception as exc:
|
|
152
159
|
logger.warning("slack_alert: failed to post: %s", exc)
|
|
160
|
+
return ""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def slack_thread_reply(bot_token: str, channel: str, thread_ts: str, text: str) -> None:
|
|
164
|
+
"""Post a reply in an existing Slack thread. No-op if thread_ts is empty."""
|
|
165
|
+
if thread_ts:
|
|
166
|
+
slack_alert(bot_token, channel, text, thread_ts=thread_ts)
|
|
153
167
|
|
|
154
168
|
|
|
155
169
|
|