@misterhuydo/sentinel 1.4.89 → 1.4.91

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.
@@ -358,6 +358,21 @@ def publish(
358
358
  return branch, pr_url
359
359
 
360
360
 
361
+ def _alert_github_token_error(cfg: "SentinelConfig", owner_repo: str, status: int, hint: str = "") -> None:
362
+ """Fire a Slack alert when GitHub API rejects the token during PR creation."""
363
+ msg = (
364
+ f":warning: *Sentinel — GitHub token error ({status})*\n"
365
+ f"Could not open a PR for `{owner_repo}`.\n"
366
+ f"{hint}\n"
367
+ f"Check `GITHUB_TOKEN` in `sentinel.properties` — it may be expired, revoked, "
368
+ f"or not scoped to this org/repo."
369
+ )
370
+ logger.error("GitHub token error %s for %s: %s", status, owner_repo, hint)
371
+ if cfg.slack_bot_token and cfg.slack_channel:
372
+ from .notify import slack_alert
373
+ slack_alert(cfg.slack_bot_token, cfg.slack_channel, msg)
374
+
375
+
361
376
  def _open_github_pr(
362
377
  event: ErrorEvent,
363
378
  repo: RepoConfig,
@@ -407,10 +422,31 @@ def _open_github_pr(
407
422
  return pr_url
408
423
  else:
409
424
  logger.error("Failed to open PR (%s): %s", resp.status_code, resp.text[:300])
425
+ if resp.status_code in (401, 403, 404):
426
+ hint = "Token may lack access to this org/repo." if resp.status_code == 404 else resp.json().get("message", "")
427
+ _alert_github_token_error(cfg, owner_repo, resp.status_code, hint)
410
428
  return ""
411
429
 
412
430
 
413
- def poll_open_prs(store, github_token: str) -> list[dict]:
431
+ def _delete_github_branch(owner_repo: str, branch: str, headers: dict) -> None:
432
+ """Delete a remote branch from GitHub after its PR is merged or closed."""
433
+ try:
434
+ resp = requests.delete(
435
+ f"https://api.github.com/repos/{owner_repo}/git/refs/heads/{branch}",
436
+ headers=headers,
437
+ timeout=15,
438
+ )
439
+ if resp.status_code == 204:
440
+ logger.info("Deleted branch %s from %s", branch, owner_repo)
441
+ elif resp.status_code == 422:
442
+ logger.debug("Branch %s already deleted on %s", branch, owner_repo)
443
+ else:
444
+ logger.warning("Could not delete branch %s (%s): %s", branch, resp.status_code, resp.text[:200])
445
+ except Exception as e:
446
+ logger.warning("Failed to delete branch %s: %s", branch, e)
447
+
448
+
449
+ def poll_open_prs(store, github_token: str, cfg: "SentinelConfig | None" = None) -> list[dict]:
414
450
  """
415
451
  Check GitHub for the current state of all pending PRs in the state store.
416
452
 
@@ -463,6 +499,14 @@ def poll_open_prs(store, github_token: str) -> list[dict]:
463
499
  if resp.status_code == 404:
464
500
  logger.warning("poll_open_prs: PR not found (deleted?): %s", pr_url)
465
501
  continue
502
+ if resp.status_code in (401, 403):
503
+ logger.error(
504
+ "poll_open_prs: GitHub token rejected (%s) for %s — "
505
+ "org may require a fine-grained PAT. Set GITHUB_TOKEN in sentinel.properties.",
506
+ resp.status_code, pr_url,
507
+ )
508
+ _alert_github_token_error(cfg, pr_url, resp.status_code, resp.json().get("message", ""))
509
+ break # No point retrying other PRs with the same bad token
466
510
  if resp.status_code != 200:
467
511
  logger.warning("poll_open_prs: unexpected %s for %s", resp.status_code, pr_url)
468
512
  continue
@@ -475,6 +519,7 @@ def poll_open_prs(store, github_token: str) -> list[dict]:
475
519
  continue # still pending
476
520
 
477
521
  new_status = "merged" if merged else "skipped"
522
+ branch = fix.get("branch", "")
478
523
 
479
524
  with store._conn() as conn:
480
525
  conn.execute(
@@ -486,6 +531,11 @@ def poll_open_prs(store, github_token: str) -> list[dict]:
486
531
  "poll_open_prs: PR #%s %s → %s (fp=%s)",
487
532
  pr_number, owner_repo, new_status, fingerprint[:8],
488
533
  )
534
+
535
+ # Delete the fix branch from GitHub when PR is closed (merged or rejected)
536
+ if branch:
537
+ _delete_github_branch(owner_repo, branch, headers)
538
+
489
539
  changes.append({
490
540
  "fingerprint": fingerprint,
491
541
  "pr_url": pr_url,
@@ -347,7 +347,8 @@ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: Stat
347
347
  if status != "patch" or patch_path is None:
348
348
  store.record_fix(event.fingerprint, "skipped" if status in ("skip", "needs_human") else "failed",
349
349
  repo_name=repo.repo_name)
350
- reason_text = marker if status == "needs_human" else f"Claude Code returned {status.upper()}"
350
+ raw_reason = marker if status == "needs_human" else f"Claude Code returned {status.upper()}"
351
+ reason_text = _boss_qualify_dev_reason(raw_reason, sentinel) if status == "needs_human" else raw_reason
351
352
  _progress(f":x: Could not generate a safe fix — {reason_text[:120]}")
352
353
  notify_fix_blocked(sentinel, event.source, event.message,
353
354
  reason=reason_text, repo_name=repo.repo_name,
@@ -548,7 +549,7 @@ async def poll_cycle(cfg_loader: ConfigLoader, store: StateStore):
548
549
  send_confirmed_notification(cfg_loader.sentinel, confirmed)
549
550
 
550
551
  # ── PR status sync — detect merges/rejections done via GitHub UI ─────────
551
- pr_changes = poll_open_prs(store, cfg_loader.sentinel.github_token)
552
+ pr_changes = poll_open_prs(store, cfg_loader.sentinel.github_token, cfg=cfg_loader.sentinel)
552
553
  for ch in pr_changes:
553
554
  if ch["new_status"] == "merged":
554
555
  logger.info("PR merged externally: %s (fp=%s)", ch["pr_url"], ch["fingerprint"][:8])
@@ -924,6 +925,268 @@ async def _sync_loop(cfg_loader: ConfigLoader):
924
925
  await asyncio.sleep(cfg_loader.sentinel.sync_interval_seconds)
925
926
 
926
927
 
928
+ # ── Patch agent (Sentinel self-improvement) ──────────────────────────────────
929
+
930
+
931
+ def _boss_qualify_dev_reason(raw: str, sentinel) -> str:
932
+ """
933
+ Boss-qualify a raw Patch reason string (from NEEDS_HUMAN: or SKIP:).
934
+
935
+ Passes the raw text through the Boss LLM to produce a clean, concise,
936
+ user-friendly explanation — so users never see verbose Claude output
937
+ directly. Falls back to a truncated version if the API call fails.
938
+ """
939
+ if not raw.strip():
940
+ return "(no reason given)"
941
+ if not sentinel.anthropic_api_key:
942
+ # No API key — just truncate and clean up
943
+ return raw[:280].strip()
944
+ try:
945
+ import anthropic as _anthropic
946
+ _client = _anthropic.Anthropic(api_key=sentinel.anthropic_api_key)
947
+ _resp = _client.messages.create(
948
+ model="claude-haiku-4-5-20251001",
949
+ max_tokens=200,
950
+ system=(
951
+ "You are Sentinel Boss, a DevOps agent assistant. "
952
+ "Patch (an autonomous dev agent) produced the following explanation for why it "
953
+ "could not complete a task. Rewrite it as a clear, concise (1-3 sentences), "
954
+ "user-friendly message suitable for a Slack channel. "
955
+ "Be direct and specific. Do not pad with pleasantries. "
956
+ "Do not start with 'I' or mention 'Patch' by name. "
957
+ "Output only the qualified message, nothing else."
958
+ ),
959
+ messages=[{"role": "user", "content": f"Patch said:\n{raw[:1000]}"}],
960
+ )
961
+ qualified = _resp.content[0].text.strip() if _resp.content else raw[:280]
962
+ return qualified[:400]
963
+ except Exception as _e:
964
+ logger.warning("Boss: could not qualify dev reason via API: %s", _e)
965
+ return raw[:280].strip()
966
+
967
+
968
+ async def _handle_dev_task(task, cfg_loader: ConfigLoader, store: StateStore):
969
+ """Execute a single dev task via Patch, post progress to Slack."""
970
+ from .sentinel_dev import run_dev_task
971
+ from .dev_watcher import mark_dev_done
972
+ from .notify import slack_alert as _slack_alert, slack_thread_reply as _slack_reply
973
+
974
+ sentinel = cfg_loader.sentinel
975
+ _submitter = task.submitter_user_id
976
+ _started_msg = (
977
+ f":wrench: Patch working on *<@{_submitter}>*'s request\n_{task.message[:120]}_"
978
+ ) if _submitter else (
979
+ f":wrench: Patch working on dev task\n_{task.message[:120]}_"
980
+ )
981
+ _thread_ts = _slack_alert(sentinel.slack_bot_token, sentinel.slack_channel, _started_msg)
982
+
983
+ def _progress(msg: str) -> None:
984
+ _slack_reply(sentinel.slack_bot_token, sentinel.slack_channel, _thread_ts, msg)
985
+
986
+ _loop = asyncio.get_event_loop()
987
+ try:
988
+ status, detail = await _loop.run_in_executor(
989
+ None, run_dev_task, task, sentinel, store, _progress
990
+ )
991
+ except Exception:
992
+ logger.exception("Patch: unexpected error on task %s", task.fingerprint[:8])
993
+ _progress(":x: Patch hit an unexpected error — check logs")
994
+ mark_dev_done(task.task_file)
995
+ return
996
+
997
+ mark_dev_done(task.task_file)
998
+
999
+ # Build mention string: submitter + any extra notify users
1000
+ _notify_ids = list(task.notify_user_ids or [])
1001
+ if task.submitter_user_id:
1002
+ mentions = f"<@{task.submitter_user_id}> " + " ".join(
1003
+ f"<@{u}>" for u in _notify_ids if u != task.submitter_user_id
1004
+ )
1005
+ mentions = mentions.strip() + " "
1006
+ else:
1007
+ mentions = " ".join(f"<@{u}>" for u in _notify_ids)
1008
+ mentions = (mentions + " ") if mentions else ""
1009
+
1010
+ if status == "done":
1011
+ _slack_alert(
1012
+ sentinel.slack_bot_token, sentinel.slack_channel,
1013
+ f"{mentions}:white_check_mark: *Patch finished* — changes committed to Sentinel source.",
1014
+ )
1015
+ elif status == "needs_human":
1016
+ # Boss qualifies the raw Patch explanation before surfacing to users
1017
+ qualified = _boss_qualify_dev_reason(detail, sentinel)
1018
+ _slack_alert(
1019
+ sentinel.slack_bot_token, sentinel.slack_channel,
1020
+ f"{mentions}:warning: *Dev task needs human input*\n{qualified}",
1021
+ )
1022
+ elif status == "skip":
1023
+ qualified = _boss_qualify_dev_reason(detail, sentinel)
1024
+ _slack_alert(
1025
+ sentinel.slack_bot_token, sentinel.slack_channel,
1026
+ f"{mentions}:fast_forward: *Dev task skipped* — {qualified}",
1027
+ )
1028
+ else:
1029
+ _slack_alert(
1030
+ sentinel.slack_bot_token, sentinel.slack_channel,
1031
+ f"{mentions}:x: *Patch error* on task `{task.fingerprint[:8]}` — {detail[:200]}",
1032
+ )
1033
+
1034
+
1035
+ async def _dev_poll_loop(cfg_loader: ConfigLoader, store: StateStore):
1036
+ """
1037
+ Background task: poll dev-tasks/ every 60 s and dispatch to Patch.
1038
+ Also scans Sentinel's own log for errors and auto-queues self-repair tasks.
1039
+ """
1040
+ from .dev_watcher import (
1041
+ scan_dev_tasks, purge_old_dev_tasks,
1042
+ scan_sentinel_errors, drop_self_repair_task,
1043
+ )
1044
+
1045
+ # Tracks fingerprints already queued this session (in-memory dedup across polls)
1046
+ _seen_self_fps: set = set()
1047
+
1048
+ # Pre-populate from existing done/cancelled tasks so we don't re-queue on restart
1049
+ project_dir = Path(".")
1050
+ for done_dir in [
1051
+ project_dir / "dev-tasks" / ".done",
1052
+ project_dir / "dev-tasks" / ".cancelled",
1053
+ ]:
1054
+ if done_dir.exists():
1055
+ for f in done_dir.iterdir():
1056
+ if f.stem.startswith("self-"):
1057
+ parts = f.stem.split("-")
1058
+ if len(parts) >= 2:
1059
+ _seen_self_fps.add(parts[1])
1060
+
1061
+ # Wait a bit so the main loop and Boss are fully up first
1062
+ await asyncio.sleep(15)
1063
+
1064
+ while True:
1065
+ try:
1066
+ if cfg_loader.sentinel.sentinel_dev_repo_path:
1067
+ purge_old_dev_tasks(project_dir / "dev-tasks")
1068
+
1069
+ # ── Self-repair: scan Sentinel's own log for new errors ────────
1070
+ log_path = project_dir / "logs" / "sentinel.log"
1071
+ new_errors = scan_sentinel_errors(log_path, seen_fps=_seen_self_fps)
1072
+ for fp, task_body in new_errors:
1073
+ logger.info("Dev agent: self-repair task queued for error %s", fp[:8])
1074
+ drop_self_repair_task(project_dir, fp, task_body)
1075
+
1076
+ # ── Process all pending dev tasks (self-repair + human-submitted) ─
1077
+ tasks = scan_dev_tasks(project_dir)
1078
+ if tasks:
1079
+ logger.info("Dev agent: %d task(s) found", len(tasks))
1080
+ for task in tasks:
1081
+ await _handle_dev_task(task, cfg_loader, store)
1082
+ except Exception as e:
1083
+ logger.warning("Dev poll loop error: %s", e)
1084
+ await asyncio.sleep(60)
1085
+
1086
+
1087
+ # ── Repo task agent (human-requested changes to managed repos) ────────────────
1088
+
1089
+ async def _handle_repo_task(task, repo_cfg, cfg_loader: ConfigLoader, store: StateStore):
1090
+ """Execute a single repo task via Claude Code, post progress to Slack."""
1091
+ from .repo_task_engine import run_repo_task, mark_repo_task_done
1092
+ from .notify import slack_alert as _slack_alert, slack_thread_reply as _slack_reply
1093
+
1094
+ sentinel = cfg_loader.sentinel
1095
+ _submitter = task.submitter_user_id
1096
+ _started_msg = (
1097
+ f":hammer: Working on *<@{_submitter}>*'s request for `{task.repo_name}`\n_{task.message[:120]}_"
1098
+ ) if _submitter else (
1099
+ f":hammer: Working on repo task for `{task.repo_name}`\n_{task.message[:120]}_"
1100
+ )
1101
+ _thread_ts = _slack_alert(sentinel.slack_bot_token, sentinel.slack_channel, _started_msg)
1102
+
1103
+ def _progress(msg: str) -> None:
1104
+ _slack_reply(sentinel.slack_bot_token, sentinel.slack_channel, _thread_ts, msg)
1105
+
1106
+ _loop = asyncio.get_event_loop()
1107
+ try:
1108
+ status, detail = await _loop.run_in_executor(
1109
+ None, run_repo_task, task, repo_cfg, sentinel, store, _progress,
1110
+ )
1111
+ except Exception:
1112
+ logger.exception("Repo task: unexpected error on task %s", task.fingerprint[:8])
1113
+ _progress(":x: Unexpected error — check logs")
1114
+ mark_repo_task_done(task.task_file)
1115
+ return
1116
+
1117
+ mark_repo_task_done(task.task_file)
1118
+
1119
+ # Build mention string: submitter + extra notify users
1120
+ _notify_ids = list(task.notify_user_ids or [])
1121
+ if task.submitter_user_id:
1122
+ mentions = f"<@{task.submitter_user_id}> " + " ".join(
1123
+ f"<@{u}>" for u in _notify_ids if u != task.submitter_user_id
1124
+ )
1125
+ mentions = mentions.strip() + " "
1126
+ else:
1127
+ mentions = " ".join(f"<@{u}>" for u in _notify_ids)
1128
+ mentions = (mentions + " ") if mentions else ""
1129
+
1130
+ if status == "done":
1131
+ if detail: # PR URL
1132
+ _slack_alert(
1133
+ sentinel.slack_bot_token, sentinel.slack_channel,
1134
+ f"{mentions}:white_check_mark: Done — PR opened for `{task.repo_name}`: {detail}",
1135
+ )
1136
+ else:
1137
+ _slack_alert(
1138
+ sentinel.slack_bot_token, sentinel.slack_channel,
1139
+ f"{mentions}:white_check_mark: Done — changes pushed to `{task.repo_name}/{repo_cfg.branch}`.",
1140
+ )
1141
+ elif status == "needs_human":
1142
+ qualified = _boss_qualify_dev_reason(detail, sentinel)
1143
+ _slack_alert(
1144
+ sentinel.slack_bot_token, sentinel.slack_channel,
1145
+ f"{mentions}:warning: *Task needs human input* (`{task.repo_name}`)\n{qualified}",
1146
+ )
1147
+ elif status == "skip":
1148
+ qualified = _boss_qualify_dev_reason(detail, sentinel)
1149
+ _slack_alert(
1150
+ sentinel.slack_bot_token, sentinel.slack_channel,
1151
+ f"{mentions}:fast_forward: Task skipped for `{task.repo_name}` — {qualified}",
1152
+ )
1153
+ else:
1154
+ _slack_alert(
1155
+ sentinel.slack_bot_token, sentinel.slack_channel,
1156
+ f"{mentions}:x: Task error for `{task.repo_name}` — {(detail or '')[:200]}",
1157
+ )
1158
+
1159
+
1160
+ async def _repo_task_poll_loop(cfg_loader: ConfigLoader, store: StateStore):
1161
+ """Background task: poll repo-tasks/ every 60s and dispatch to Claude."""
1162
+ from .repo_task_engine import scan_repo_tasks, mark_repo_task_done
1163
+
1164
+ await asyncio.sleep(20)
1165
+
1166
+ while True:
1167
+ try:
1168
+ project_dir = Path(".")
1169
+ tasks = scan_repo_tasks(project_dir)
1170
+ if tasks:
1171
+ logger.info("Repo task: %d task(s) found", len(tasks))
1172
+ for task in tasks:
1173
+ # Resolve repo config — exact match then fuzzy
1174
+ repo_cfg = cfg_loader.repos.get(task.repo_name)
1175
+ if not repo_cfg:
1176
+ for rname, rcfg in cfg_loader.repos.items():
1177
+ if task.repo_name.lower() in rname.lower():
1178
+ repo_cfg = rcfg
1179
+ break
1180
+ if not repo_cfg:
1181
+ logger.warning("Repo task: no config for repo '%s' — skipping", task.repo_name)
1182
+ mark_repo_task_done(task.task_file)
1183
+ continue
1184
+ await _handle_repo_task(task, repo_cfg, cfg_loader, store)
1185
+ except Exception as e:
1186
+ logger.warning("Repo task poll loop error: %s", e)
1187
+ await asyncio.sleep(60)
1188
+
1189
+
927
1190
  # ── Entry point ──────────────────────────────────────────────────────────────────────────────────
928
1191
 
929
1192
  def _log_auth_status(cfg: SentinelConfig) -> None:
@@ -995,6 +1258,9 @@ async def run_loop(cfg_loader: ConfigLoader, store: StateStore):
995
1258
  if cfg_loader.sentinel.slack_bot_token:
996
1259
  from .slack_bot import run_slack_bot
997
1260
  asyncio.ensure_future(run_slack_bot(cfg_loader, store))
1261
+ if cfg_loader.sentinel.sentinel_dev_repo_path:
1262
+ asyncio.ensure_future(_dev_poll_loop(cfg_loader, store))
1263
+ asyncio.ensure_future(_repo_task_poll_loop(cfg_loader, store))
998
1264
 
999
1265
  while True:
1000
1266
  try: