@misterhuydo/sentinel 1.5.5 → 1.5.7

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.
Files changed (37) hide show
  1. package/.cairn/.hint-lock +1 -1
  2. package/.cairn/minify-map.json +7 -1
  3. package/.cairn/session.json +17 -5
  4. package/.cairn/views/23edf4_sentinel_boss.py +3664 -0
  5. package/.cairn/views/62a614_bundle.js +4 -1
  6. package/.cairn/views/7802b9_cicd_trigger.py +171 -0
  7. package/.cairn/views/ac3df4_repo_task_engine.py +351 -0
  8. package/lib/.cairn/minify-map.json +6 -0
  9. package/lib/.cairn/views/2a85cc_init.js +380 -0
  10. package/lib/.cairn/views/e26996_slack-setup.js +97 -0
  11. package/lib/.cairn/views/fb78ac_upgrade.js +36 -1
  12. package/lib/.cairn/views/fc4a1a_add.js +164 -51
  13. package/lib/init.js +54 -0
  14. package/lib/maven.js +212 -0
  15. package/lib/slack-setup.js +5 -0
  16. package/package.json +1 -1
  17. package/python/requirements.txt +1 -0
  18. package/python/sentinel/.cairn/.cairn-project +0 -0
  19. package/python/sentinel/.cairn/.hint-lock +1 -0
  20. package/python/sentinel/.cairn/session.json +9 -0
  21. package/python/sentinel/__pycache__/sentinel_boss.cpython-311.pyc +0 -0
  22. package/python/sentinel/config_loader.py +29 -10
  23. package/python/sentinel/dependency_manager.py +9 -2
  24. package/python/sentinel/git_manager.py +23 -0
  25. package/python/sentinel/issue_watcher.py +7 -1
  26. package/python/sentinel/main.py +346 -7
  27. package/python/sentinel/notify.py +44 -12
  28. package/python/sentinel/repo_task_engine.py +37 -3
  29. package/python/sentinel/sentinel_boss.py +213 -6
  30. package/python/sentinel/slack_bot.py +15 -2
  31. package/python/sentinel/state_store.py +0 -1
  32. package/python/tests/__init__.py +0 -0
  33. package/python/tests/test_config_loader.py +138 -0
  34. package/python/tests/test_log_parser.py +62 -0
  35. package/python/tests/test_repo_router.py +73 -0
  36. package/python/tests/test_smoke.py +96 -0
  37. package/python/tests/test_state_store.py +128 -0
@@ -193,6 +193,35 @@ def slack_dm(bot_token: str, user_id: str, text: str) -> None:
193
193
  logger.warning("slack_dm: failed to DM %s: %s", user_id, exc)
194
194
 
195
195
 
196
+ def notify_nexus_auth_failure(cfg, repo_name: str, context: str, mvn_output: str) -> None:
197
+ """
198
+ DM the first admin user when a Maven build fails due to missing/invalid Nexus credentials.
199
+ Called from git_manager, dependency_manager, and sentinel_boss (chain_release) when
200
+ MavenAuthError is raised.
201
+ """
202
+ if not cfg.slack_admin_users or not cfg.slack_bot_token:
203
+ logger.warning("Cannot DM admin about Nexus auth failure — no admin users or bot token configured")
204
+ return
205
+ snippet = mvn_output[-400:].strip() if mvn_output else ""
206
+ lines = [
207
+ f":lock: *Maven authentication failed* during `{context}` on `{repo_name}`",
208
+ "",
209
+ "Nexus credentials in `~/.m2/settings.xml` are missing or invalid.",
210
+ "",
211
+ "Fix it by sending me one of:",
212
+ "",
213
+ "*Option 1 — paste your settings.xml*",
214
+ "`nexus settings`",
215
+ "then the full XML content on the next line(s).",
216
+ "",
217
+ "*Option 2 — credentials per host*",
218
+ "`nexus creds <host> <username> <password>`",
219
+ ]
220
+ if snippet:
221
+ lines += ["", f"```\n{snippet}\n```"]
222
+ slack_dm(cfg.slack_bot_token, cfg.slack_admin_users[0], "\n".join(lines))
223
+
224
+
196
225
  def notify_fix_blocked(
197
226
  cfg,
198
227
  source: str,
@@ -200,13 +229,13 @@ def notify_fix_blocked(
200
229
  reason: str,
201
230
  repo_name: str = "",
202
231
  submitter_user_id: str = "",
232
+ origin_channel: str = "",
203
233
  ) -> None:
204
234
  """
205
235
  Notify that a fix needs human intervention.
206
236
 
207
- - If submitter_user_id is known: DM that person directly.
208
- - Otherwise: @channel in the configured Slack channel.
209
- - Always: email admins via reporter.send_failure_notification.
237
+ Posts to origin_channel if set (where the issue was raised), otherwise
238
+ falls back to cfg.slack_channel. Always emails admins.
210
239
  """
211
240
  short_reason = (reason or "Claude could not determine a safe fix.")[:600]
212
241
  repo_line = f"\n*Repo:* {repo_name}" if repo_name else ""
@@ -218,11 +247,11 @@ def notify_fix_blocked(
218
247
  f"*Reason:*\n{short_reason}"
219
248
  )
220
249
 
221
- # Post to channel — mention submitter if known, otherwise broadcast
250
+ target_channel = origin_channel or cfg.slack_channel
222
251
  if submitter_user_id:
223
- slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<@{submitter_user_id}> {slack_text}")
252
+ slack_alert(cfg.slack_bot_token, target_channel, f"<@{submitter_user_id}> {slack_text}")
224
253
  else:
225
- slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<!channel> {slack_text}")
254
+ slack_alert(cfg.slack_bot_token, target_channel, f"<!channel> {slack_text}")
226
255
 
227
256
  # Always email admins
228
257
  try:
@@ -260,6 +289,7 @@ def notify_missing_tool(
260
289
  repo_name: str,
261
290
  source: str,
262
291
  submitter_user_id: str = "",
292
+ origin_channel: str = "",
263
293
  ) -> None:
264
294
  """
265
295
  Notify admins that a build tool is missing on this server.
@@ -282,10 +312,11 @@ def notify_missing_tool(
282
312
  f"*How to fix:*\n{steps}\n\n"
283
313
  f"Then tell me: `retry {repo_name or source}`"
284
314
  )
315
+ target_channel = origin_channel or cfg.slack_channel
285
316
  if submitter_user_id:
286
- slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<@{submitter_user_id}> {slack_text}")
317
+ slack_alert(cfg.slack_bot_token, target_channel, f"<@{submitter_user_id}> {slack_text}")
287
318
  else:
288
- slack_alert(cfg.slack_bot_token, cfg.slack_channel, f"<!channel> {slack_text}")
319
+ slack_alert(cfg.slack_bot_token, target_channel, f"<!channel> {slack_text}")
289
320
 
290
321
 
291
322
  def notify_fix_applied(
@@ -296,10 +327,11 @@ def notify_fix_applied(
296
327
  branch: str,
297
328
  pr_url: str,
298
329
  submitter_user_id: str = "",
330
+ origin_channel: str = "",
299
331
  ) -> None:
300
332
  """
301
- DM the submitter (if known) that their issue was fixed.
302
- Falls back to posting in the Slack channel if no submitter.
333
+ Notify that a fix was applied. Posts to origin_channel if set (where
334
+ the issue was raised), otherwise falls back to cfg.slack_channel.
303
335
  """
304
336
  repo_line = f" in *{repo_name}*" if repo_name else ""
305
337
  if pr_url:
@@ -315,9 +347,9 @@ def notify_fix_applied(
315
347
  + (f"{action_line}\n" if action_line else "")
316
348
  ).rstrip()
317
349
 
318
- # Post to channel — mention submitter if known
350
+ target_channel = origin_channel or cfg.slack_channel
319
351
  channel_text = f"<@{submitter_user_id}> {slack_text}" if submitter_user_id else slack_text
320
- slack_alert(cfg.slack_bot_token, cfg.slack_channel, channel_text)
352
+ slack_alert(cfg.slack_bot_token, target_channel, channel_text)
321
353
 
322
354
 
323
355
  def notify_cascade_started(
@@ -12,6 +12,7 @@ File format:
12
12
  TYPE: feature|fix|refactor|chore
13
13
  SUBMITTED_BY: <@UXXX> (UXXX)
14
14
  SUBMITTED_AT: 2026-03-27T10:00:00+00:00
15
+ RUN_AT: 2026-03-28T02:00:00+00:00 # optional — task is held until this UTC time
15
16
  NOTIFY: U1234567,U7654321 # optional
16
17
 
17
18
  Full task description — what to implement, fix, or refactor.
@@ -33,7 +34,7 @@ from .git_manager import _git_env
33
34
 
34
35
  logger = logging.getLogger(__name__)
35
36
 
36
- _META_PREFIXES = ("REPO:", "TYPE:", "SUBMITTED_BY:", "SUBMITTED_AT:", "NOTIFY:")
37
+ _META_PREFIXES = ("REPO:", "TYPE:", "SUBMITTED_BY:", "SUBMITTED_AT:", "RUN_AT:", "NOTIFY:")
37
38
  _TASK_TIMEOUT = 900 # 15 minutes
38
39
 
39
40
 
@@ -48,6 +49,7 @@ class RepoTask:
48
49
  notify_user_ids: list = field(default_factory=list)
49
50
  fingerprint: str = ""
50
51
  timestamp: str = ""
52
+ run_at: datetime | None = None # UTC; task is held until this time if set
51
53
 
52
54
  def __post_init__(self):
53
55
  if not self.fingerprint:
@@ -331,8 +333,13 @@ def drop_repo_task(
331
333
  description: str,
332
334
  submitter_user_id: str = "",
333
335
  notify_user_ids: list | None = None,
336
+ run_at: datetime | None = None,
334
337
  ) -> Path:
335
- """Drop a repo task file into <project_dir>/repo-tasks/."""
338
+ """Drop a repo task file into <project_dir>/repo-tasks/.
339
+
340
+ If run_at (UTC-aware datetime) is given the task will be held by the poll
341
+ loop until that time has passed before executing.
342
+ """
336
343
  tasks_dir = project_dir / "repo-tasks"
337
344
  tasks_dir.mkdir(exist_ok=True)
338
345
  import uuid as _uuid
@@ -346,11 +353,16 @@ def drop_repo_task(
346
353
  if submitter_user_id else "SUBMITTED_BY: system"),
347
354
  f"SUBMITTED_AT: {datetime.now(timezone.utc).isoformat()}",
348
355
  ]
356
+ if run_at is not None:
357
+ lines.append(f"RUN_AT: {run_at.astimezone(timezone.utc).isoformat()}")
349
358
  if notify_user_ids:
350
359
  lines.append(f"NOTIFY: {','.join(notify_user_ids)}")
351
360
  lines += ["", description]
352
361
  fpath.write_text("\n".join(lines), encoding="utf-8")
353
- logger.info("Dropped repo task: %s", fname)
362
+ if run_at:
363
+ logger.info("Dropped repo task (scheduled): %s — run_at=%s", fname, run_at.isoformat())
364
+ else:
365
+ logger.info("Dropped repo task: %s", fname)
354
366
  return fpath
355
367
 
356
368
 
@@ -375,6 +387,7 @@ def scan_repo_tasks(project_dir: Path) -> list[RepoTask]:
375
387
  task_type = "feature"
376
388
  submitter_user_id = ""
377
389
  notify_user_ids: list = []
390
+ run_at: datetime | None = None
378
391
  body_start = 0
379
392
 
380
393
  for i, line in enumerate(lines):
@@ -392,6 +405,13 @@ def scan_repo_tasks(project_dir: Path) -> list[RepoTask]:
392
405
  if m:
393
406
  submitter_user_id = m.group(1)
394
407
  body_start = i + 1
408
+ elif upper.startswith("RUN_AT:"):
409
+ raw_ts = stripped[7:].strip()
410
+ try:
411
+ run_at = datetime.fromisoformat(raw_ts).astimezone(timezone.utc)
412
+ except ValueError:
413
+ logger.warning("Repo task %s: invalid RUN_AT value '%s' — ignoring", f.name, raw_ts)
414
+ body_start = i + 1
395
415
  elif upper.startswith("NOTIFY:"):
396
416
  notify_user_ids = [u.strip() for u in stripped[7:].split(",") if u.strip()]
397
417
  body_start = i + 1
@@ -407,6 +427,19 @@ def scan_repo_tasks(project_dir: Path) -> list[RepoTask]:
407
427
  logger.warning("Repo task %s has no REPO: header — skipping", f.name)
408
428
  continue
409
429
 
430
+ # Hold scheduled tasks until their run_at time has passed
431
+ if run_at is not None:
432
+ now_utc = datetime.now(timezone.utc)
433
+ if now_utc < run_at:
434
+ remaining = run_at - now_utc
435
+ hours, rem = divmod(int(remaining.total_seconds()), 3600)
436
+ minutes = rem // 60
437
+ logger.debug(
438
+ "Repo task %s scheduled for %s — holding (%dh %dm remaining)",
439
+ f.name, run_at.isoformat(), hours, minutes,
440
+ )
441
+ continue
442
+
410
443
  tasks.append(RepoTask(
411
444
  task_file=f,
412
445
  repo_name=repo_name,
@@ -415,6 +448,7 @@ def scan_repo_tasks(project_dir: Path) -> list[RepoTask]:
415
448
  message=message,
416
449
  submitter_user_id=submitter_user_id,
417
450
  notify_user_ids=notify_user_ids,
451
+ run_at=run_at,
418
452
  ))
419
453
  logger.info("Found repo task: %s → %s (type=%s)", f.name, repo_name, task_type)
420
454
 
@@ -516,6 +516,8 @@ When to act vs. when to ask:
516
516
  - Write/action tools (create_issue, trigger_poll, pull_repo, pause_sentinel, install_tool, merge_pr,
517
517
  retry_issue, cancel_issue, post_file) → act immediately for clear commands; confirm only when intent is ambiguous
518
518
  (e.g. unclear which project or repo to target).
519
+ - trigger_poll → trigger it, then IMMEDIATELY call get_status and report what is happening.
520
+ Never stop at "poll triggered" — the human wants to know the outcome, not that the trigger was sent.
519
521
  - Explaining a tool ("what does X do?") → explain naturally, then offer to run it if relevant.
520
522
  - NEVER gate investigation on user approval. If diagnosing a problem, run all relevant read tools
521
523
  first, then present findings. Asking "Want me to look?" wastes a round trip.
@@ -578,7 +580,9 @@ Sentinel is a 24/7 autonomous agent. Act on clear signals without asking permiss
578
580
  3. Populate `findings` with curated evidence — only when relevant and concise:
579
581
  - Summarise: which services, how often, key pattern, 1-3 example lines. Max 500 words.
580
582
  - Do NOT paste raw tool output.
581
- 4. After creating, tell them the issue was queued and Sentinel will pick it up on the next poll.
583
+ 4. After creating, immediately call get_status and report: how many items are now in the queue,
584
+ what is already in progress, and when the next poll will pick things up.
585
+ Tell them they will get an @mention in this channel when the fix is applied or blocked.
582
586
 
583
587
  Autonomous action policy — Sentinel acts, humans review outcomes:
584
588
  - Read/investigate tools → always act immediately, no confirmation needed.
@@ -588,6 +592,35 @@ Autonomous action policy — Sentinel acts, humans review outcomes:
588
592
  - pause_sentinel → confirm once (halts all monitoring).
589
593
  - Everything else → use judgment. When in doubt, act and report what you did.
590
594
 
595
+ PROACTIVE COMMUNICATION — CRITICAL RULES:
596
+ You are a push-first agent. The engineer should NEVER have to ask for a status update
597
+ on something you already know about or can find out.
598
+
599
+ BANNED phrases (never say these):
600
+ - "Want me to check again in a few minutes?"
601
+ - "Should I check on that?"
602
+ - "I'll keep an eye on it."
603
+ - "I'll monitor that for you."
604
+ - "Let me know if you'd like an update."
605
+ - Anything that puts the follow-up burden back on the human.
606
+
607
+ Instead:
608
+ - If checking is warranted → check now, report the result in the same message.
609
+ - If something is running async (fix, task, poll) → tell the human EXACTLY what automated
610
+ notification they will receive and when. Example: "The main loop will post here when the
611
+ fix is applied or fails — usually within 2–3 minutes."
612
+ - After trigger_poll → wait for it, then call get_status immediately and report findings.
613
+ Do not stop at "poll triggered" — that is not an answer, it is a non-answer.
614
+
615
+ The main poll loop sends proactive Slack messages automatically when:
616
+ - A fix is applied (commit + PR opened)
617
+ - A fix is blocked (test failure, patch too large, etc.)
618
+ - A fix is confirmed (no recurrence for MARKER_CONFIRM_HOURS)
619
+ - A repo task or dev task completes or fails
620
+ - A health check recovers
621
+ You do NOT need to promise to monitor — the system already does it. Tell users this explicitly
622
+ when it is relevant, then end with [DONE].
623
+
591
624
  When the engineer's request is fully handled, end your LAST message with the token: [DONE]
592
625
  IMPORTANT: Always write your actual reply text FIRST, then append [DONE] at the end. Example: "Hello! I'm Sentinel. [DONE]". Never output [DONE] as your only content.
593
626
  For greetings like "hello" or empty messages, introduce yourself briefly and offer help, then end with [DONE].
@@ -657,9 +690,11 @@ _TOOLS = [
657
690
  {
658
691
  "name": "get_status",
659
692
  "description": (
660
- "Get recent errors, fixes applied, fixes pending review, and open PRs. "
693
+ "Get recent errors, fixes applied, fixes pending review, open PRs, "
694
+ "and the live task queue (issues and repo tasks waiting to be picked up). "
661
695
  "Use for: 'what happened today?', 'any issues?', 'how are things?', "
662
- "'what are the open PRs?', 'did sentinel fix anything?'"
696
+ "'what are the open PRs?', 'did sentinel fix anything?', "
697
+ "'what is queued?', 'what is pending?', 'progress on X?'"
663
698
  ),
664
699
  "input_schema": {
665
700
  "type": "object",
@@ -814,12 +849,13 @@ _TOOLS = [
814
849
  {
815
850
  "name": "repo_task",
816
851
  "description": (
817
- "ADMIN ONLY. Submit a feature, fix, or refactor task for a managed repo. "
852
+ "ADMIN ONLY. Submit a feature, fix, refactor, or scheduled deploy task for a managed repo. "
818
853
  "Claude Code will run against the repo's local clone, implement the change, "
819
854
  "commit, and push (or open a PR if AUTO_PUBLISH=false). "
820
855
  "ALWAYS gather a complete spec first — ask follow-up questions until unambiguous. "
821
856
  "Use for: 'add X to elprint-connector-service', 'fix Y in cairn', "
822
- "'refactor OrderService', any human-requested change to a managed repo."
857
+ "'refactor OrderService', 'deploy UIB at 3 AM tomorrow', "
858
+ "any human-requested change to a managed repo — immediate or scheduled."
823
859
  ),
824
860
  "input_schema": {
825
861
  "type": "object",
@@ -843,6 +879,16 @@ _TOOLS = [
843
879
  "items": {"type": "string"},
844
880
  "description": "Extra Slack user IDs to ping on completion (besides the submitter).",
845
881
  },
882
+ "run_at": {
883
+ "type": "string",
884
+ "description": (
885
+ "Optional. Schedule the task for a future time. "
886
+ "Accepts natural language (e.g. '3 AM tomorrow Norwegian time', "
887
+ "'kl 03:00 norsk tid', 'at 15:30 CET', 'in 2 hours') "
888
+ "or an ISO 8601 datetime string. "
889
+ "If omitted the task runs immediately on the next poll cycle."
890
+ ),
891
+ },
846
892
  },
847
893
  "required": ["repo_name", "description"],
848
894
  },
@@ -1628,6 +1674,79 @@ _TOOLS = [
1628
1674
  ]
1629
1675
 
1630
1676
 
1677
+ # ── Scheduling helpers ────────────────────────────────────────────────────────
1678
+
1679
+ def _parse_run_at(raw: str) -> tuple:
1680
+ """Parse a natural-language or ISO 8601 time expression into a UTC-aware datetime.
1681
+
1682
+ Returns (datetime, None) on success, (None, error_str) on failure.
1683
+
1684
+ Understands:
1685
+ - ISO 8601 strings with or without timezone
1686
+ - "at HH:MM [TZ]" / "kl HH:MM [norsk tid|CET|CEST|Oslo]"
1687
+ - "in N hours/minutes"
1688
+ - "tomorrow" modifier before or after HH:MM
1689
+ - Norwegian timezone keywords → Europe/Oslo
1690
+ """
1691
+ import re as _re
1692
+ from datetime import datetime as _dt, timezone as _tz, timedelta as _td
1693
+
1694
+ _UTC = _tz.utc
1695
+
1696
+ # Build Oslo timezone — prefer zoneinfo (accurate DST), fall back to fixed offset
1697
+ try:
1698
+ import zoneinfo as _zi
1699
+ _OSLO = _zi.ZoneInfo("Europe/Oslo")
1700
+ except Exception:
1701
+ # tzdata not installed or unavailable; use fixed UTC+1 (CET, conservative)
1702
+ _OSLO = _tz(_td(hours=1)) # type: ignore[assignment]
1703
+
1704
+ raw_l = raw.lower().strip()
1705
+
1706
+ # ── ISO 8601 ───────────────────────────────────────────────────────────────
1707
+ try:
1708
+ parsed = _dt.fromisoformat(raw)
1709
+ if parsed.tzinfo is None:
1710
+ parsed = parsed.replace(tzinfo=_OSLO)
1711
+ return parsed.astimezone(_UTC), None
1712
+ except ValueError:
1713
+ pass
1714
+
1715
+ # ── "in N hours/minutes" ──────────────────────────────────────────────────
1716
+ m = _re.search(r'in\s+(\d+)\s*(hour|hr|minute|min)', raw_l)
1717
+ if m:
1718
+ n = int(m.group(1))
1719
+ unit = m.group(2)
1720
+ delta = _td(hours=n) if unit.startswith("h") else _td(minutes=n)
1721
+ return _dt.now(_UTC) + delta, None
1722
+
1723
+ # ── "at HH:MM" / "kl HH:MM" with optional TZ and "tomorrow" ──────────────
1724
+ m = _re.search(r'(?:kl\.?\s*|at\s+)?(\d{1,2})[:\.](\d{2})', raw_l)
1725
+ if m:
1726
+ hour, minute = int(m.group(1)), int(m.group(2))
1727
+
1728
+ tz_hint = raw_l
1729
+ if any(kw in tz_hint for kw in ("norsk", "oslo", "norway", "norwegian", "cet", "cest")):
1730
+ tz = _OSLO
1731
+ elif "utc" in tz_hint:
1732
+ tz = _UTC
1733
+ else:
1734
+ tz = _OSLO # default for this project
1735
+
1736
+ today = _dt.now(tz).date()
1737
+ tomorrow = today + _td(days=1)
1738
+ base_date = tomorrow if "tomorrow" in raw_l or "i morgen" in raw_l else today
1739
+
1740
+ candidate = _dt(base_date.year, base_date.month, base_date.day, hour, minute, tzinfo=tz)
1741
+ # If the time is already past today, roll to tomorrow automatically
1742
+ if candidate <= _dt.now(tz) and "tomorrow" not in raw_l and "i morgen" not in raw_l:
1743
+ candidate += _td(days=1)
1744
+
1745
+ return candidate.astimezone(_UTC), None
1746
+
1747
+ return None, f"Unrecognised time expression: '{raw}'"
1748
+
1749
+
1631
1750
  # ── Workspace helpers ─────────────────────────────────────────────────────────
1632
1751
 
1633
1752
  def _workspace_dir() -> Path:
@@ -1745,6 +1864,61 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
1745
1864
  }
1746
1865
  for e in errors[:8]
1747
1866
  ]
1867
+
1868
+ # ── Live queue scan ────────────────────────────────────────────────────
1869
+ # Show what is physically in the queue dirs, not just what is in the DB.
1870
+ from datetime import datetime as _dt, timezone as _tz
1871
+ _now = _dt.now(_tz.utc)
1872
+
1873
+ def _queue_entries(queue_dir: Path) -> list[dict]:
1874
+ """Return pending task/issue files with age and first-line preview."""
1875
+ entries = []
1876
+ if not queue_dir.exists():
1877
+ return entries
1878
+ for f in sorted(queue_dir.iterdir()):
1879
+ if not f.is_file() or f.name.startswith("."):
1880
+ continue
1881
+ try:
1882
+ lines = f.read_text(encoding="utf-8", errors="replace").splitlines()
1883
+ except OSError:
1884
+ continue
1885
+ # Parse RUN_AT if present (scheduled tasks held for the future)
1886
+ run_at_str = next(
1887
+ (l.split(":", 1)[1].strip() for l in lines
1888
+ if l.strip().upper().startswith("RUN_AT:")), None
1889
+ )
1890
+ run_at_dt = None
1891
+ if run_at_str:
1892
+ try:
1893
+ run_at_dt = _dt.fromisoformat(run_at_str).astimezone(_tz.utc)
1894
+ except ValueError:
1895
+ pass
1896
+ age_s = int((_now - _dt.fromtimestamp(f.stat().st_mtime, _tz.utc)).total_seconds())
1897
+ preview = next((l.strip() for l in lines if l.strip() and not l.strip().upper().startswith(
1898
+ ("REPO:", "TYPE:", "SUBMITTED_BY:", "SUBMITTED_AT:", "RUN_AT:", "NOTIFY:",
1899
+ "SOURCE:", "TARGET_REPO:", "FINGERPRINT:", "SUPPORT_URL:")
1900
+ )), f.name)
1901
+ entry = {
1902
+ "file": f.name,
1903
+ "age_seconds": age_s,
1904
+ "preview": preview[:120],
1905
+ }
1906
+ if run_at_dt:
1907
+ remaining = int((run_at_dt - _now).total_seconds())
1908
+ if remaining > 0:
1909
+ entry["scheduled_in_seconds"] = remaining
1910
+ entry["scheduled_at"] = run_at_dt.isoformat()
1911
+ entries.append(entry)
1912
+ return entries
1913
+
1914
+ _project_dirs = _find_project_dirs()
1915
+ _pd = _project_dirs[0] if _project_dirs else Path(".")
1916
+ queued_issues = _queue_entries(_pd / "issues")
1917
+ queued_repo_tasks = _queue_entries(_pd / "repo-tasks")
1918
+ # Scheduled tasks are repo-tasks with a future run_at
1919
+ scheduled_tasks = [t for t in queued_repo_tasks if "scheduled_in_seconds" in t]
1920
+ pending_repo_tasks = [t for t in queued_repo_tasks if "scheduled_in_seconds" not in t]
1921
+
1748
1922
  return json.dumps({
1749
1923
  "window_hours": hours,
1750
1924
  "errors_detected": len(errors),
@@ -1762,6 +1936,9 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
1762
1936
  for p in prs
1763
1937
  ],
1764
1938
  "sentinel_paused": Path("SENTINEL_PAUSE").exists(),
1939
+ "queued_issues": queued_issues,
1940
+ "queued_repo_tasks": pending_repo_tasks,
1941
+ "scheduled_tasks": scheduled_tasks,
1765
1942
  })
1766
1943
 
1767
1944
  if name == "check_auth_status":
@@ -2181,6 +2358,15 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
2181
2358
  if isinstance(notify_ids, list):
2182
2359
  notify_ids = [u for u in notify_ids if u and u != user_id]
2183
2360
 
2361
+ # Parse optional scheduled time
2362
+ run_at_raw = (inputs.get("run_at") or "").strip()
2363
+ run_at_dt = None
2364
+ run_at_parse_error = None
2365
+ if run_at_raw:
2366
+ run_at_dt, run_at_parse_error = _parse_run_at(run_at_raw)
2367
+ if run_at_parse_error:
2368
+ return json.dumps({"error": f"Could not parse run_at: {run_at_parse_error}"})
2369
+
2184
2370
  _project_dirs = _find_project_dirs()
2185
2371
  if not _project_dirs:
2186
2372
  return json.dumps({"error": "No project directory found."})
@@ -2193,8 +2379,29 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
2193
2379
  description=description,
2194
2380
  submitter_user_id=user_id,
2195
2381
  notify_user_ids=notify_ids,
2382
+ run_at=run_at_dt,
2383
+ )
2384
+ logger.info(
2385
+ "Boss repo_task: dropped %s for user %s (repo=%s, run_at=%s)",
2386
+ task_file.name, user_id, repo_name,
2387
+ run_at_dt.isoformat() if run_at_dt else "immediate",
2196
2388
  )
2197
- logger.info("Boss repo_task: dropped %s for user %s (repo=%s)", task_file.name, user_id, repo_name)
2389
+ if run_at_dt:
2390
+ import zoneinfo as _zi
2391
+ oslo = _zi.ZoneInfo("Europe/Oslo")
2392
+ local_str = run_at_dt.astimezone(oslo).strftime("%Y-%m-%d %H:%M %Z")
2393
+ return json.dumps({
2394
+ "status": "scheduled",
2395
+ "repo": repo_name,
2396
+ "task_type": task_type,
2397
+ "file": task_file.name,
2398
+ "run_at_utc": run_at_dt.isoformat(),
2399
+ "run_at_local": local_str,
2400
+ "note": (
2401
+ f"Task scheduled for `{repo_name}` at {local_str} — "
2402
+ "Sentinel will pick it up automatically when the time arrives."
2403
+ ),
2404
+ })
2198
2405
  return json.dumps({
2199
2406
  "status": "queued",
2200
2407
  "repo": repo_name,
@@ -60,6 +60,10 @@ async def _get_or_create_session(user_id: str, user_name: str, channel: str) ->
60
60
  async with _sessions_lock:
61
61
  if user_id not in _sessions:
62
62
  _sessions[user_id] = _Session(user_id, user_name, channel)
63
+ else:
64
+ # Always update channel so replies go to wherever the current message came from,
65
+ # not the channel where the session was first created (e.g. a prior DM).
66
+ _sessions[user_id].channel = channel
63
67
  return _sessions[user_id]
64
68
 
65
69
 
@@ -353,12 +357,21 @@ async def _dispatch(event: dict, client, cfg_loader, store) -> None:
353
357
  if not text:
354
358
  text = "hello"
355
359
 
356
- # Allowlist check — if SLACK_ALLOWED_USERS is configured, silently ignore everyone else
357
- # Admins (SLACK_ADMIN_USERS) are always allowed regardless of SLACK_ALLOWED_USERS
360
+ # Allowlist check — if SLACK_ALLOWED_USERS is configured, only those users + admins may interact.
361
+ # Admins (SLACK_ADMIN_USERS) are always allowed regardless of SLACK_ALLOWED_USERS.
358
362
  allowed = cfg_loader.sentinel.slack_allowed_users
359
363
  admin_users = cfg_loader.sentinel.slack_admin_users or []
360
364
  if allowed and user_id not in allowed and user_id not in admin_users:
361
365
  logger.warning("Boss: ignoring message from unauthorised user %s", user_id)
366
+ # For DMs: send a polite rejection so the user knows they're not authorized
367
+ if event.get("channel_type") == "im":
368
+ try:
369
+ await client.chat_postMessage(
370
+ channel=channel,
371
+ text="Sorry, you're not authorized to interact with Sentinel. Contact an admin to get access.",
372
+ )
373
+ except Exception:
374
+ pass
362
375
  return
363
376
 
364
377
  user_name, user_tz = await _resolve_name(client, user_id)
@@ -540,7 +540,6 @@ class StateStore:
540
540
  """Remove a fix record so Sentinel will retry the error on the next poll."""
541
541
  with self._conn() as conn:
542
542
  cur = conn.execute("DELETE FROM fixes WHERE fingerprint = ?", (fingerprint,))
543
- conn.execute("UPDATE errors SET last_seen = NULL WHERE fingerprint = ?", (fingerprint,))
544
543
  return cur.rowcount > 0
545
544
 
546
545
  def get_all_errors(self, hours: int = 0) -> list[dict]:
File without changes