@misterhuydo/sentinel 1.4.90 → 1.4.92
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/lib/.cairn/views/fb78ac_upgrade.js +1 -1
- package/lib/add.js +8 -20
- package/lib/generate.js +7 -1
- package/lib/upgrade.js +39 -1
- package/package.json +1 -1
- package/python/sentinel/config_loader.py +10 -6
- package/python/sentinel/dev_watcher.py +9 -1
- package/python/sentinel/fix_engine.py +1 -1
- package/python/sentinel/main.py +131 -24
- package/python/sentinel/repo_task_engine.py +381 -0
- package/python/sentinel/sentinel_boss.py +275 -23
- package/python/sentinel/sentinel_dev.py +30 -72
- package/templates/log-configs/_example.properties +21 -32
- package/templates/sentinel.properties +5 -6
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-03-
|
|
1
|
+
2026-03-27T13:47:13.440Z
|
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-03-
|
|
3
|
-
"checkpoint_at": "2026-03-
|
|
2
|
+
"message": "Auto-checkpoint at 2026-03-27T13:53:02.113Z",
|
|
3
|
+
"checkpoint_at": "2026-03-27T13:53:02.114Z",
|
|
4
4
|
"active_files": [],
|
|
5
5
|
"notes": [],
|
|
6
6
|
"mtime_snapshot": {}
|
|
@@ -62,7 +62,7 @@ module.exports = async function upgrade() {
|
|
|
62
62
|
const { version: installed } = require(path.join(npmRootEarly, '@misterhuydo', 'sentinel', 'package.json'));
|
|
63
63
|
if (installed !== current) {
|
|
64
64
|
ok(`Upgraded: ${current} → ${installed} — re-running with new version...`);
|
|
65
|
-
const newBin = path.join(
|
|
65
|
+
const newBin = path.join(npmRootEarly, '..', '..', 'bin', 'sentinel');
|
|
66
66
|
const r = spawnSync(newBin, ['upgrade'], { stdio: 'inherit' });
|
|
67
67
|
process.exit(r.status || 0);
|
|
68
68
|
}
|
package/lib/add.js
CHANGED
|
@@ -616,34 +616,22 @@ async function addFromGit(gitUrl, workspace) {
|
|
|
616
616
|
generateWorkspaceScripts(workspace, {}, {}, {}, effectiveToken);
|
|
617
617
|
}
|
|
618
618
|
|
|
619
|
-
// Write private
|
|
619
|
+
// Write private tokens to private_sentinel.properties (gitignored — never committed)
|
|
620
620
|
if (projectSlackBotToken || projectSlackAppToken) {
|
|
621
|
-
|
|
622
|
-
const
|
|
623
|
-
const lines = ['# Private Slack tokens for this project — DO NOT COMMIT'];
|
|
621
|
+
const privateProps = path.join(projectDir, 'private_sentinel.properties');
|
|
622
|
+
const lines = ['# Private credentials for this project — DO NOT COMMIT'];
|
|
624
623
|
if (projectSlackBotToken) lines.push(`SLACK_BOT_TOKEN=${projectSlackBotToken}`);
|
|
625
624
|
if (projectSlackAppToken) lines.push(`SLACK_APP_TOKEN=${projectSlackAppToken}`);
|
|
626
|
-
fs.writeFileSync(
|
|
627
|
-
ok('Private
|
|
625
|
+
fs.writeFileSync(privateProps, lines.join('\n') + '\n');
|
|
626
|
+
ok('Private tokens → private_sentinel.properties (local only)');
|
|
628
627
|
|
|
629
|
-
// Ensure
|
|
628
|
+
// Ensure private_sentinel.properties is gitignored
|
|
630
629
|
const gitignore = path.join(projectDir, '.gitignore');
|
|
631
|
-
const ignoreEntry = '
|
|
630
|
+
const ignoreEntry = 'private_sentinel.properties';
|
|
632
631
|
const existing = fs.existsSync(gitignore) ? fs.readFileSync(gitignore, 'utf8') : '';
|
|
633
632
|
if (!existing.includes(ignoreEntry)) {
|
|
634
633
|
fs.writeFileSync(gitignore, existing.trimEnd() + `\n${ignoreEntry}\n`);
|
|
635
|
-
ok('.gitignore updated —
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
// Set PRIVATE_SLACK=true in config/sentinel.properties
|
|
639
|
-
const projProps = path.join(projectDir, 'config', 'sentinel.properties');
|
|
640
|
-
if (fs.existsSync(projProps)) {
|
|
641
|
-
let props = fs.readFileSync(projProps, 'utf8');
|
|
642
|
-
props = /^#?\s*PRIVATE_SLACK\s*=/m.test(props)
|
|
643
|
-
? props.replace(/^#?\s*PRIVATE_SLACK\s*=.*/m, 'PRIVATE_SLACK=true')
|
|
644
|
-
: props.trimEnd() + '\nPRIVATE_SLACK=true\n';
|
|
645
|
-
fs.writeFileSync(projProps, props);
|
|
646
|
-
ok('PRIVATE_SLACK=true set in config/sentinel.properties');
|
|
634
|
+
ok('.gitignore updated — private_sentinel.properties will not be committed');
|
|
647
635
|
}
|
|
648
636
|
}
|
|
649
637
|
|
package/lib/generate.js
CHANGED
|
@@ -312,6 +312,12 @@ for project_dir in "$WORKSPACE"/*/; do
|
|
|
312
312
|
done
|
|
313
313
|
echo "[sentinel] $stopped project(s) stopped"
|
|
314
314
|
`, { mode: 0o755 });
|
|
315
|
+
// watchdog.sh - restarts any project that has stopped unexpectedly.
|
|
316
|
+
// Auto-installed as a cron job by `sentinel upgrade`.
|
|
317
|
+
fs.writeFileSync(path.join(workspace, 'watchdog.sh'),
|
|
318
|
+
"#!/usr/bin/env bash\n# Sentinel watchdog - auto-restart any project that has stopped unexpectedly.\n# Runs every minute via cron. Safe to run manually at any time.\nWORKSPACE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nNON_PROJECT=\"code repos logs issues workspace\"\nfor project_dir in \"$WORKSPACE\"/*/; do\n [[ -d \"$project_dir\" ]] || continue\n name=$(basename \"$project_dir\")\n echo \" $NON_PROJECT \" | grep -qw \"$name\" && continue\n [[ -f \"$project_dir/start.sh\" ]] || continue\n [[ -f \"$project_dir/config/sentinel.properties\" ]] || continue\n PID_FILE=\"$project_dir/sentinel.pid\"\n if [[ -f \"$PID_FILE\" ]] && kill -0 \"$(cat \"$PID_FILE\")\" 2>/dev/null; then\n continue # running fine\n fi\n echo \"[watchdog] $(date -u +%Y-%m-%dT%H:%M:%SZ) $name is down - restarting\"\n bash \"$project_dir/start.sh\"\ndone\n",
|
|
319
|
+
{ mode: 0o755 });
|
|
315
320
|
}
|
|
316
|
-
|
|
321
|
+
|
|
322
|
+
|
|
317
323
|
module.exports = { writeExampleProject, generateProjectScripts, generateWorkspaceScripts };
|
package/lib/upgrade.js
CHANGED
|
@@ -146,13 +146,51 @@ module.exports = async function upgrade() {
|
|
|
146
146
|
warn('Could not regenerate project scripts: ' + e.message);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// Install watchdog cron job if not already present
|
|
150
|
+
const watchdog = path.join(defaultWorkspace, 'watchdog.sh');
|
|
151
|
+
const logsDir = path.join(defaultWorkspace, 'logs');
|
|
152
|
+
if (fs.existsSync(watchdog)) {
|
|
153
|
+
info('Installing watchdog cron job...');
|
|
154
|
+
try {
|
|
155
|
+
const existing = execSync('crontab -l 2>/dev/null || true', { encoding: 'utf8' });
|
|
156
|
+
if (existing.includes('watchdog.sh')) {
|
|
157
|
+
ok('Watchdog cron job already installed');
|
|
158
|
+
} else {
|
|
159
|
+
const cronLine = `* * * * * ${watchdog} >> ${logsDir}/watchdog.log 2>&1`;
|
|
160
|
+
const newCron = existing.trimEnd() + '\n' + cronLine + '\n';
|
|
161
|
+
execSync(`echo ${JSON.stringify(newCron)} | crontab -`);
|
|
162
|
+
ok('Watchdog cron job installed (runs every minute)');
|
|
163
|
+
}
|
|
164
|
+
} catch (e) {
|
|
165
|
+
warn('Could not install watchdog cron: ' + e.message);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
149
169
|
const startAll = path.join(defaultWorkspace, 'startAll.sh');
|
|
150
170
|
const stopAll = path.join(defaultWorkspace, 'stopAll.sh');
|
|
151
171
|
if (fs.existsSync(stopAll) && fs.existsSync(startAll)) {
|
|
152
172
|
info('Restarting Sentinel...');
|
|
153
173
|
spawnSync('bash', [stopAll], { stdio: 'inherit' });
|
|
154
174
|
spawnSync('bash', [startAll], { stdio: 'inherit' });
|
|
155
|
-
|
|
175
|
+
// Verify restart succeeded — give processes 4 seconds to write PID files
|
|
176
|
+
execSync('sleep 4 || true');
|
|
177
|
+
let allUp = true;
|
|
178
|
+
try {
|
|
179
|
+
const entries = fs.readdirSync(defaultWorkspace, { withFileTypes: true });
|
|
180
|
+
const NON = new Set(['logs', 'code', 'repos', 'workspace', 'issues']);
|
|
181
|
+
for (const e of entries) {
|
|
182
|
+
if (!e.isDirectory() || e.name.startsWith('.') || NON.has(e.name)) continue;
|
|
183
|
+
const pidFile = path.join(defaultWorkspace, e.name, 'sentinel.pid');
|
|
184
|
+
if (!fs.existsSync(path.join(defaultWorkspace, e.name, 'config', 'sentinel.properties'))) continue;
|
|
185
|
+
if (!fs.existsSync(pidFile)) { allUp = false; continue; }
|
|
186
|
+
const pid = fs.readFileSync(pidFile, 'utf8').trim();
|
|
187
|
+
try { execSync(`kill -0 ${pid}`); } catch (_) {
|
|
188
|
+
warn(`${e.name} may have crashed after restart — watchdog will recover it within 1 minute`);
|
|
189
|
+
allUp = false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} catch (_) {}
|
|
193
|
+
if (allUp) ok('Sentinel restarted');
|
|
156
194
|
} else {
|
|
157
195
|
warn('No startAll.sh found — restart manually');
|
|
158
196
|
}
|
package/package.json
CHANGED
|
@@ -76,7 +76,6 @@ class SentinelConfig:
|
|
|
76
76
|
sync_max_file_mb: int = 200 # truncate synced log files exceeding this size (MB)
|
|
77
77
|
boss_mode: str = "standard" # standard | strict | fun
|
|
78
78
|
sentinel_dev_repo_path: str = "" # path to Sentinel source repo for Dev Claude
|
|
79
|
-
sentinel_dev_auto_publish: bool = False # if True, auto-publish + upgrade after version bump
|
|
80
79
|
|
|
81
80
|
|
|
82
81
|
@dataclass
|
|
@@ -153,14 +152,20 @@ class ConfigLoader:
|
|
|
153
152
|
project_d = _parse_properties(str(path))
|
|
154
153
|
d.update({k: v for k, v in project_d.items() if v})
|
|
155
154
|
|
|
156
|
-
#
|
|
157
|
-
#
|
|
158
|
-
|
|
155
|
+
# Load private_sentinel.properties if present (gitignored — tokens never in config repo).
|
|
156
|
+
# Supports any secret key: SLACK_BOT_TOKEN, SLACK_APP_TOKEN, GITHUB_TOKEN, etc.
|
|
157
|
+
# Falls back to legacy slack.properties (PRIVATE_SLACK=true) for existing installs.
|
|
158
|
+
_private = self.config_dir.parent / "private_sentinel.properties"
|
|
159
|
+
if _private.exists():
|
|
160
|
+
_pd = _parse_properties(str(_private))
|
|
161
|
+
d.update({k: v for k, v in _pd.items() if v})
|
|
162
|
+
logger.debug("Loaded private config from %s", _private)
|
|
163
|
+
elif d.get("PRIVATE_SLACK", "").lower() == "true":
|
|
159
164
|
slack_props = self.config_dir.parent / "slack.properties"
|
|
160
165
|
if slack_props.exists():
|
|
161
166
|
slack_d = _parse_properties(str(slack_props))
|
|
162
167
|
d.update({k: v for k, v in slack_d.items() if v})
|
|
163
|
-
logger.debug("Loaded private
|
|
168
|
+
logger.debug("Loaded private config from %s (legacy)", slack_props)
|
|
164
169
|
else:
|
|
165
170
|
logger.warning("PRIVATE_SLACK=true but %s not found — run `sentinel add` to create it", slack_props)
|
|
166
171
|
|
|
@@ -209,7 +214,6 @@ class ConfigLoader:
|
|
|
209
214
|
raw_mode = d.get("BOSS_MODE", "standard").lower().strip()
|
|
210
215
|
c.boss_mode = raw_mode if raw_mode in ("standard", "strict", "fun") else "standard"
|
|
211
216
|
c.sentinel_dev_repo_path = d.get("SENTINEL_DEV_REPO_PATH", "")
|
|
212
|
-
c.sentinel_dev_auto_publish = d.get("SENTINEL_DEV_AUTO_PUBLISH", "false").lower() == "true"
|
|
213
217
|
self.sentinel = c
|
|
214
218
|
|
|
215
219
|
def _load_log_sources(self):
|
|
@@ -14,6 +14,7 @@ File format:
|
|
|
14
14
|
SOURCE: boss|fix_engine/BOSS_ESCALATE|self_repair|manual
|
|
15
15
|
SOURCE_FINGERPRINT: <8-char error fingerprint> # optional
|
|
16
16
|
SUBMITTED_AT: 2026-03-27T10:00:00+00:00
|
|
17
|
+
NOTIFY: U1234567,U7654321 # optional extra users to ping on completion
|
|
17
18
|
|
|
18
19
|
Task description — what to implement, fix, or improve in Sentinel.
|
|
19
20
|
"""
|
|
@@ -30,7 +31,7 @@ import re
|
|
|
30
31
|
|
|
31
32
|
logger = logging.getLogger(__name__)
|
|
32
33
|
|
|
33
|
-
_META_PREFIXES = ("TYPE:", "SUBMITTED_BY:", "SOURCE:", "SOURCE_FINGERPRINT:", "SUBMITTED_AT:")
|
|
34
|
+
_META_PREFIXES = ("TYPE:", "SUBMITTED_BY:", "SOURCE:", "SOURCE_FINGERPRINT:", "SUBMITTED_AT:", "NOTIFY:")
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
@dataclass
|
|
@@ -44,6 +45,7 @@ class DevTask:
|
|
|
44
45
|
timestamp: str = ""
|
|
45
46
|
submitter_user_id: str = ""
|
|
46
47
|
source_fingerprint: str = "" # error fingerprint if from BOSS_ESCALATE
|
|
48
|
+
notify_user_ids: list = field(default_factory=list) # extra users to ping on completion
|
|
47
49
|
|
|
48
50
|
def __post_init__(self):
|
|
49
51
|
if not self.fingerprint:
|
|
@@ -89,6 +91,7 @@ def scan_dev_tasks(project_dir: Path) -> list[DevTask]:
|
|
|
89
91
|
task_type = "feature"
|
|
90
92
|
source_fingerprint = ""
|
|
91
93
|
submitter_user_id = ""
|
|
94
|
+
notify_user_ids: list = []
|
|
92
95
|
body_start = 0
|
|
93
96
|
|
|
94
97
|
for i, line in enumerate(lines):
|
|
@@ -106,6 +109,10 @@ def scan_dev_tasks(project_dir: Path) -> list[DevTask]:
|
|
|
106
109
|
elif upper.startswith("SOURCE_FINGERPRINT:"):
|
|
107
110
|
source_fingerprint = stripped[19:].strip()
|
|
108
111
|
body_start = i + 1
|
|
112
|
+
elif upper.startswith("NOTIFY:"):
|
|
113
|
+
raw_ids = stripped[7:].strip()
|
|
114
|
+
notify_user_ids = [u.strip() for u in raw_ids.split(",") if u.strip()]
|
|
115
|
+
body_start = i + 1
|
|
109
116
|
elif any(upper.startswith(p) for p in _META_PREFIXES) or not stripped:
|
|
110
117
|
body_start = i + 1
|
|
111
118
|
else:
|
|
@@ -122,6 +129,7 @@ def scan_dev_tasks(project_dir: Path) -> list[DevTask]:
|
|
|
122
129
|
task_type=task_type,
|
|
123
130
|
submitter_user_id=submitter_user_id,
|
|
124
131
|
source_fingerprint=source_fingerprint,
|
|
132
|
+
notify_user_ids=notify_user_ids,
|
|
125
133
|
))
|
|
126
134
|
logger.info("Found dev task: %s (type=%s)", f.name, task_type)
|
|
127
135
|
|
|
@@ -129,7 +129,7 @@ def _build_prompt(event, repo: RepoConfig, log_file, marker: str, stale_markers:
|
|
|
129
129
|
"8. If the fix requires changing Sentinel's own source code (the monitoring/fix agent",
|
|
130
130
|
" itself, not the application being monitored) — output exactly:",
|
|
131
131
|
" BOSS_ESCALATE: <description of what needs to change in Sentinel>",
|
|
132
|
-
" This escalates to the Sentinel
|
|
132
|
+
" This escalates to Patch, the Sentinel dev agent, who will implement it.",
|
|
133
133
|
]
|
|
134
134
|
return "\n".join(lines_out)
|
|
135
135
|
|
package/python/sentinel/main.py
CHANGED
|
@@ -925,12 +925,12 @@ async def _sync_loop(cfg_loader: ConfigLoader):
|
|
|
925
925
|
await asyncio.sleep(cfg_loader.sentinel.sync_interval_seconds)
|
|
926
926
|
|
|
927
927
|
|
|
928
|
-
# ──
|
|
928
|
+
# ── Patch agent (Sentinel self-improvement) ──────────────────────────────────
|
|
929
929
|
|
|
930
930
|
|
|
931
931
|
def _boss_qualify_dev_reason(raw: str, sentinel) -> str:
|
|
932
932
|
"""
|
|
933
|
-
Boss-qualify a raw
|
|
933
|
+
Boss-qualify a raw Patch reason string (from NEEDS_HUMAN: or SKIP:).
|
|
934
934
|
|
|
935
935
|
Passes the raw text through the Boss LLM to produce a clean, concise,
|
|
936
936
|
user-friendly explanation — so users never see verbose Claude output
|
|
@@ -949,14 +949,14 @@ def _boss_qualify_dev_reason(raw: str, sentinel) -> str:
|
|
|
949
949
|
max_tokens=200,
|
|
950
950
|
system=(
|
|
951
951
|
"You are Sentinel Boss, a DevOps agent assistant. "
|
|
952
|
-
"
|
|
952
|
+
"Patch (an autonomous dev agent) produced the following explanation for why it "
|
|
953
953
|
"could not complete a task. Rewrite it as a clear, concise (1-3 sentences), "
|
|
954
954
|
"user-friendly message suitable for a Slack channel. "
|
|
955
955
|
"Be direct and specific. Do not pad with pleasantries. "
|
|
956
|
-
"Do not start with 'I' or '
|
|
956
|
+
"Do not start with 'I' or mention 'Patch' by name. "
|
|
957
957
|
"Output only the qualified message, nothing else."
|
|
958
958
|
),
|
|
959
|
-
messages=[{"role": "user", "content": f"
|
|
959
|
+
messages=[{"role": "user", "content": f"Patch said:\n{raw[:1000]}"}],
|
|
960
960
|
)
|
|
961
961
|
qualified = _resp.content[0].text.strip() if _resp.content else raw[:280]
|
|
962
962
|
return qualified[:400]
|
|
@@ -966,7 +966,7 @@ def _boss_qualify_dev_reason(raw: str, sentinel) -> str:
|
|
|
966
966
|
|
|
967
967
|
|
|
968
968
|
async def _handle_dev_task(task, cfg_loader: ConfigLoader, store: StateStore):
|
|
969
|
-
"""Execute a single dev task via
|
|
969
|
+
"""Execute a single dev task via Patch, post progress to Slack."""
|
|
970
970
|
from .sentinel_dev import run_dev_task
|
|
971
971
|
from .dev_watcher import mark_dev_done
|
|
972
972
|
from .notify import slack_alert as _slack_alert, slack_thread_reply as _slack_reply
|
|
@@ -974,9 +974,9 @@ async def _handle_dev_task(task, cfg_loader: ConfigLoader, store: StateStore):
|
|
|
974
974
|
sentinel = cfg_loader.sentinel
|
|
975
975
|
_submitter = task.submitter_user_id
|
|
976
976
|
_started_msg = (
|
|
977
|
-
f":
|
|
977
|
+
f":wrench: Patch working on *<@{_submitter}>*'s request\n_{task.message[:120]}_"
|
|
978
978
|
) if _submitter else (
|
|
979
|
-
f":
|
|
979
|
+
f":wrench: Patch working on dev task\n_{task.message[:120]}_"
|
|
980
980
|
)
|
|
981
981
|
_thread_ts = _slack_alert(sentinel.slack_bot_token, sentinel.slack_channel, _started_msg)
|
|
982
982
|
|
|
@@ -989,49 +989,52 @@ async def _handle_dev_task(task, cfg_loader: ConfigLoader, store: StateStore):
|
|
|
989
989
|
None, run_dev_task, task, sentinel, store, _progress
|
|
990
990
|
)
|
|
991
991
|
except Exception:
|
|
992
|
-
logger.exception("
|
|
993
|
-
_progress(":x:
|
|
992
|
+
logger.exception("Patch: unexpected error on task %s", task.fingerprint[:8])
|
|
993
|
+
_progress(":x: Patch hit an unexpected error — check logs")
|
|
994
994
|
mark_dev_done(task.task_file)
|
|
995
995
|
return
|
|
996
996
|
|
|
997
997
|
mark_dev_done(task.task_file)
|
|
998
998
|
|
|
999
|
-
mention
|
|
1000
|
-
|
|
1001
|
-
if
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
f"{mention}:rocket: *Dev Claude published* `v{detail}` — upgrading Sentinel...",
|
|
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
|
|
1005
1004
|
)
|
|
1006
|
-
|
|
1007
|
-
|
|
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":
|
|
1008
1011
|
_slack_alert(
|
|
1009
1012
|
sentinel.slack_bot_token, sentinel.slack_channel,
|
|
1010
|
-
f"{
|
|
1013
|
+
f"{mentions}:white_check_mark: *Patch finished* — changes committed to Sentinel source.",
|
|
1011
1014
|
)
|
|
1012
1015
|
elif status == "needs_human":
|
|
1013
|
-
# Boss qualifies the raw
|
|
1016
|
+
# Boss qualifies the raw Patch explanation before surfacing to users
|
|
1014
1017
|
qualified = _boss_qualify_dev_reason(detail, sentinel)
|
|
1015
1018
|
_slack_alert(
|
|
1016
1019
|
sentinel.slack_bot_token, sentinel.slack_channel,
|
|
1017
|
-
f"{
|
|
1020
|
+
f"{mentions}:warning: *Dev task needs human input*\n{qualified}",
|
|
1018
1021
|
)
|
|
1019
1022
|
elif status == "skip":
|
|
1020
1023
|
qualified = _boss_qualify_dev_reason(detail, sentinel)
|
|
1021
1024
|
_slack_alert(
|
|
1022
1025
|
sentinel.slack_bot_token, sentinel.slack_channel,
|
|
1023
|
-
f"{
|
|
1026
|
+
f"{mentions}:fast_forward: *Dev task skipped* — {qualified}",
|
|
1024
1027
|
)
|
|
1025
1028
|
else:
|
|
1026
1029
|
_slack_alert(
|
|
1027
1030
|
sentinel.slack_bot_token, sentinel.slack_channel,
|
|
1028
|
-
f"{
|
|
1031
|
+
f"{mentions}:x: *Patch error* on task `{task.fingerprint[:8]}` — {detail[:200]}",
|
|
1029
1032
|
)
|
|
1030
1033
|
|
|
1031
1034
|
|
|
1032
1035
|
async def _dev_poll_loop(cfg_loader: ConfigLoader, store: StateStore):
|
|
1033
1036
|
"""
|
|
1034
|
-
Background task: poll dev-tasks/ every 60 s and dispatch to
|
|
1037
|
+
Background task: poll dev-tasks/ every 60 s and dispatch to Patch.
|
|
1035
1038
|
Also scans Sentinel's own log for errors and auto-queues self-repair tasks.
|
|
1036
1039
|
"""
|
|
1037
1040
|
from .dev_watcher import (
|
|
@@ -1081,6 +1084,109 @@ async def _dev_poll_loop(cfg_loader: ConfigLoader, store: StateStore):
|
|
|
1081
1084
|
await asyncio.sleep(60)
|
|
1082
1085
|
|
|
1083
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
|
+
|
|
1084
1190
|
# ── Entry point ──────────────────────────────────────────────────────────────────────────────────
|
|
1085
1191
|
|
|
1086
1192
|
def _log_auth_status(cfg: SentinelConfig) -> None:
|
|
@@ -1154,6 +1260,7 @@ async def run_loop(cfg_loader: ConfigLoader, store: StateStore):
|
|
|
1154
1260
|
asyncio.ensure_future(run_slack_bot(cfg_loader, store))
|
|
1155
1261
|
if cfg_loader.sentinel.sentinel_dev_repo_path:
|
|
1156
1262
|
asyncio.ensure_future(_dev_poll_loop(cfg_loader, store))
|
|
1263
|
+
asyncio.ensure_future(_repo_task_poll_loop(cfg_loader, store))
|
|
1157
1264
|
|
|
1158
1265
|
while True:
|
|
1159
1266
|
try:
|