ctrlrelay 0.10.0__py3-none-any.whl
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.
- ctrlrelay/__init__.py +33 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +89 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +740 -0
- ctrlrelay/bridge/telegram_handler.py +136 -0
- ctrlrelay/cli.py +3138 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +778 -0
- ctrlrelay/core/dispatcher.py +372 -0
- ctrlrelay/core/github.py +432 -0
- ctrlrelay/core/network.py +277 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +920 -0
- ctrlrelay/core/pr_verifier.py +230 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/question_expiry.py +193 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +642 -0
- ctrlrelay/core/worktree.py +893 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/gh_protocol.py +47 -0
- ctrlrelay/install.py +203 -0
- ctrlrelay/personalization/__init__.py +26 -0
- ctrlrelay/personalization/manager.py +1183 -0
- ctrlrelay/personalization/paths.py +143 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +1367 -0
- ctrlrelay/pipelines/post_merge.py +411 -0
- ctrlrelay/pipelines/secops.py +1018 -0
- ctrlrelay/pipelines/task.py +713 -0
- ctrlrelay/setup.py +617 -0
- ctrlrelay/templates/__init__.py +0 -0
- ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
- ctrlrelay/templates/launchd/__init__.py +0 -0
- ctrlrelay/templates/launchd/bridge.plist.template +37 -0
- ctrlrelay/templates/launchd/poller.plist.template +44 -0
- ctrlrelay/templates/systemd/__init__.py +0 -0
- ctrlrelay/templates/systemd/bridge.service.template +20 -0
- ctrlrelay/templates/systemd/poller.service.template +25 -0
- ctrlrelay/transports/__init__.py +34 -0
- ctrlrelay/transports/base.py +74 -0
- ctrlrelay/transports/file_mock.py +103 -0
- ctrlrelay/transports/socket_client.py +298 -0
- ctrlrelay-0.10.0.dist-info/METADATA +254 -0
- ctrlrelay-0.10.0.dist-info/RECORD +53 -0
- ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
- ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
- ctrlrelay-0.10.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,1018 @@
|
|
|
1
|
+
"""Secops pipeline for security triage across repos."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ctrlrelay.core.checkpoint import CheckpointStatus
|
|
13
|
+
from ctrlrelay.core.dispatcher import AgentAdapter, SessionResult
|
|
14
|
+
from ctrlrelay.core.github import GitHubCLI
|
|
15
|
+
from ctrlrelay.core.obs import get_logger, hash_text, log_event
|
|
16
|
+
from ctrlrelay.core.question_expiry import extract_referenced_numbers
|
|
17
|
+
from ctrlrelay.core.state import StateDB
|
|
18
|
+
from ctrlrelay.core.worktree import WorktreeManager
|
|
19
|
+
from ctrlrelay.dashboard.client import DashboardClient, EventPayload
|
|
20
|
+
from ctrlrelay.pipelines.base import PipelineContext, PipelineResult
|
|
21
|
+
from ctrlrelay.transports.base import Transport
|
|
22
|
+
|
|
23
|
+
_logger = get_logger("pipeline.secops")
|
|
24
|
+
|
|
25
|
+
DEFAULT_MAX_BLOCKED_ROUNDS = 5
|
|
26
|
+
|
|
27
|
+
# 30-day rolling window of operator decisions injected into each
|
|
28
|
+
# sweep's prompt. Long enough to cover weekly cadences and a vacation;
|
|
29
|
+
# short enough that a stale "approve" from months ago can't auto-merge
|
|
30
|
+
# a PR whose risk profile may have shifted (new transitive deps,
|
|
31
|
+
# changed maintainer, etc.).
|
|
32
|
+
DECISION_RECALL_SECONDS = 30 * 86400
|
|
33
|
+
|
|
34
|
+
# Shared with the question-expiry sweep, which parses the same "#N"
|
|
35
|
+
# citations out of the same questions to decide whether a question's
|
|
36
|
+
# subject has already been resolved elsewhere. One regex, one meaning.
|
|
37
|
+
_extract_pr_numbers = extract_referenced_numbers
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _record_decisions_from_answer(
|
|
41
|
+
state_db: StateDB,
|
|
42
|
+
*,
|
|
43
|
+
repo: str,
|
|
44
|
+
question: str,
|
|
45
|
+
answer: str,
|
|
46
|
+
session_id: str,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Best-effort persistence of an operator's verbatim Telegram
|
|
49
|
+
answer against every PR# mentioned in the question. Failure here
|
|
50
|
+
is logged but does not break the pipeline — the answer has
|
|
51
|
+
already been acted on by the agent; losing the persistence layer
|
|
52
|
+
just means the next sweep will re-ask, which is the pre-fix
|
|
53
|
+
status quo."""
|
|
54
|
+
pr_numbers = _extract_pr_numbers(question)
|
|
55
|
+
if not pr_numbers:
|
|
56
|
+
return
|
|
57
|
+
for pr_num in pr_numbers:
|
|
58
|
+
try:
|
|
59
|
+
state_db.record_automation_decision(
|
|
60
|
+
repo=repo,
|
|
61
|
+
operation="dependabot_pr",
|
|
62
|
+
item_id=f"#{pr_num}",
|
|
63
|
+
decision=answer,
|
|
64
|
+
decided_by="operator",
|
|
65
|
+
context=(question or "")[:500],
|
|
66
|
+
)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
log_event(
|
|
69
|
+
_logger,
|
|
70
|
+
"secops.decision.record_failed",
|
|
71
|
+
session_id=session_id,
|
|
72
|
+
repo=repo,
|
|
73
|
+
pr_number=pr_num,
|
|
74
|
+
error_type=type(e).__name__,
|
|
75
|
+
error=str(e)[:200],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _question_for_persist(session_id: str, result: PipelineResult) -> str:
|
|
80
|
+
"""Return a non-empty question string for pending_resumes storage.
|
|
81
|
+
|
|
82
|
+
When a BLOCKED result includes no question text (rare — the prompt
|
|
83
|
+
is supposed to make the agent write one), fall back to a synthesized
|
|
84
|
+
placeholder so the row is still persisted. Without this, the empty
|
|
85
|
+
question branch would silently skip the persist and the session
|
|
86
|
+
would become unresumable via Telegram."""
|
|
87
|
+
q = (result.question or "").strip()
|
|
88
|
+
if q:
|
|
89
|
+
return q
|
|
90
|
+
return (
|
|
91
|
+
f"Session {session_id} is blocked but did not include a "
|
|
92
|
+
"question. Reply with guidance to resume."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class SecopsPipeline:
|
|
98
|
+
"""Security operations pipeline for daily triage."""
|
|
99
|
+
|
|
100
|
+
dispatcher: AgentAdapter
|
|
101
|
+
github: GitHubCLI
|
|
102
|
+
worktree: WorktreeManager
|
|
103
|
+
dashboard: DashboardClient | None
|
|
104
|
+
state_db: StateDB
|
|
105
|
+
transport: Transport | None
|
|
106
|
+
|
|
107
|
+
name: str = "secops"
|
|
108
|
+
|
|
109
|
+
async def run(self, ctx: PipelineContext) -> PipelineResult:
|
|
110
|
+
"""Run secops on a single repo."""
|
|
111
|
+
prompt = self._build_prompt(
|
|
112
|
+
ctx.repo,
|
|
113
|
+
session_id=ctx.session_id,
|
|
114
|
+
state_file=ctx.state_file,
|
|
115
|
+
automation=ctx.extra.get("automation"),
|
|
116
|
+
prior_decisions=ctx.extra.get("prior_decisions"),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
result = await self._spawn(ctx, prompt, resume=False)
|
|
120
|
+
return self._session_to_result(result)
|
|
121
|
+
|
|
122
|
+
async def resume(self, ctx: PipelineContext, answer: str) -> PipelineResult:
|
|
123
|
+
"""Resume blocked secops with user answer."""
|
|
124
|
+
prompt = f"User answered: {answer}\n\nContinue from where you left off."
|
|
125
|
+
|
|
126
|
+
# Resume uses Claude's own session UUID (captured on the first spawn
|
|
127
|
+
# and persisted to state_db). Our composite id is not a UUID and will
|
|
128
|
+
# be rejected by `claude --resume` on v2.0.x+.
|
|
129
|
+
resume_uuid = self.state_db.get_agent_session_id(ctx.session_id)
|
|
130
|
+
|
|
131
|
+
log_event(
|
|
132
|
+
_logger,
|
|
133
|
+
"dev.session.resumed",
|
|
134
|
+
session_id=ctx.session_id,
|
|
135
|
+
repo=ctx.repo,
|
|
136
|
+
issue_number=ctx.issue_number,
|
|
137
|
+
pipeline=self.name,
|
|
138
|
+
resume_session_id=resume_uuid,
|
|
139
|
+
answer_length=len(answer),
|
|
140
|
+
answer_hash=hash_text(answer),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
result = await self._spawn(ctx, prompt, resume=True)
|
|
144
|
+
return self._session_to_result(result)
|
|
145
|
+
|
|
146
|
+
async def _spawn(
|
|
147
|
+
self,
|
|
148
|
+
ctx: PipelineContext,
|
|
149
|
+
prompt: str,
|
|
150
|
+
*,
|
|
151
|
+
resume: bool,
|
|
152
|
+
) -> SessionResult:
|
|
153
|
+
"""Centralized dispatcher call: looks up the agent UUID on resume,
|
|
154
|
+
persists any newly-captured UUID to state_db."""
|
|
155
|
+
resume_uuid: str | None = None
|
|
156
|
+
if resume:
|
|
157
|
+
resume_uuid = self.state_db.get_agent_session_id(ctx.session_id)
|
|
158
|
+
|
|
159
|
+
result = await self.dispatcher.spawn_session(
|
|
160
|
+
session_id=ctx.session_id,
|
|
161
|
+
prompt=prompt,
|
|
162
|
+
working_dir=ctx.worktree_path,
|
|
163
|
+
state_file=ctx.state_file,
|
|
164
|
+
resume_session_id=resume_uuid,
|
|
165
|
+
repo=ctx.repo,
|
|
166
|
+
issue_number=ctx.issue_number,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if result.agent_session_id:
|
|
170
|
+
try:
|
|
171
|
+
self.state_db.set_agent_session_id(
|
|
172
|
+
ctx.session_id, result.agent_session_id
|
|
173
|
+
)
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
def _build_prompt(
|
|
180
|
+
self,
|
|
181
|
+
repo: str,
|
|
182
|
+
session_id: str = "",
|
|
183
|
+
state_file: Path | None = None,
|
|
184
|
+
automation: Any = None,
|
|
185
|
+
prior_decisions: list[dict[str, Any]] | None = None,
|
|
186
|
+
) -> str:
|
|
187
|
+
"""Build the secops prompt.
|
|
188
|
+
|
|
189
|
+
``automation`` is an optional ``AutomationConfig`` carrying the
|
|
190
|
+
per-repo Dependabot policy (auto/ask/never per severity tier).
|
|
191
|
+
When provided, the prompt explicitly tells the agent which tiers
|
|
192
|
+
auto-merge vs ask vs never-merge. When ``None``, falls back to
|
|
193
|
+
the default policy (patch=auto, minor=ask, never-major) so
|
|
194
|
+
existing callers and the schema defaults stay coherent.
|
|
195
|
+
|
|
196
|
+
``prior_decisions`` is a list of recent automation_decisions
|
|
197
|
+
rows for this repo. When present, they're rendered as a
|
|
198
|
+
"Prior operator decisions" block so the agent can act on a
|
|
199
|
+
past answer instead of asking again about the same PR.
|
|
200
|
+
"""
|
|
201
|
+
state_file_path = str(state_file) if state_file else "/tmp/state.json"
|
|
202
|
+
|
|
203
|
+
# Translate per-repo policy into prompt directives. Use defaults
|
|
204
|
+
# from AutomationConfig if no policy was passed.
|
|
205
|
+
patch_pol = (
|
|
206
|
+
automation.dependabot_patch.value
|
|
207
|
+
if automation is not None and hasattr(automation, "dependabot_patch")
|
|
208
|
+
else "auto"
|
|
209
|
+
)
|
|
210
|
+
minor_pol = (
|
|
211
|
+
automation.dependabot_minor.value
|
|
212
|
+
if automation is not None and hasattr(automation, "dependabot_minor")
|
|
213
|
+
else "ask"
|
|
214
|
+
)
|
|
215
|
+
major_pol = (
|
|
216
|
+
automation.dependabot_major.value
|
|
217
|
+
if automation is not None and hasattr(automation, "dependabot_major")
|
|
218
|
+
else "never"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def _policy_directive(tier: str, policy: str) -> str:
|
|
222
|
+
if policy == "auto":
|
|
223
|
+
return (
|
|
224
|
+
f" - {tier}: AUTO-MERGE with passing CI. Squash + "
|
|
225
|
+
"delete branch."
|
|
226
|
+
)
|
|
227
|
+
if policy == "ask":
|
|
228
|
+
return (
|
|
229
|
+
f" - {tier}: ASK — signal BLOCKED with a one-line "
|
|
230
|
+
f"summary so the operator decides."
|
|
231
|
+
)
|
|
232
|
+
return (
|
|
233
|
+
f" - {tier}: NEVER — do not merge. Leave the PR open "
|
|
234
|
+
"for manual review."
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
dependabot_policy_block = "\n".join(
|
|
238
|
+
[
|
|
239
|
+
_policy_directive("patch updates", patch_pol),
|
|
240
|
+
_policy_directive("minor updates", minor_pol),
|
|
241
|
+
_policy_directive("major updates", major_pol),
|
|
242
|
+
]
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Render the rolling window of prior operator decisions for this
|
|
246
|
+
# repo. The agent reads the verbatim Telegram answer and decides
|
|
247
|
+
# whether the prior call still applies — we don't try to
|
|
248
|
+
# normalize "yes"/"approve"/"merge it" to a canonical decision
|
|
249
|
+
# because that classification is exactly what the agent does
|
|
250
|
+
# best, and getting it wrong silently auto-merges PRs.
|
|
251
|
+
#
|
|
252
|
+
# We render the stored ``context`` (question snippet captured
|
|
253
|
+
# at decision time) alongside item_id + answer so the agent
|
|
254
|
+
# can compare prior bump/CI state with the PR it's looking at
|
|
255
|
+
# right now. Without context, the "only re-ASK if circumstances
|
|
256
|
+
# materially changed" rule below is unenforceable — the agent
|
|
257
|
+
# can't detect a force-pushed PR that swapped the version bump
|
|
258
|
+
# under the same PR number.
|
|
259
|
+
if prior_decisions:
|
|
260
|
+
lines = ["## Prior operator decisions (last 30 days)", ""]
|
|
261
|
+
for d in prior_decisions:
|
|
262
|
+
ts = d.get("decided_at") or 0
|
|
263
|
+
date = (
|
|
264
|
+
time.strftime("%Y-%m-%d", time.gmtime(int(ts)))
|
|
265
|
+
if ts else "unknown-date"
|
|
266
|
+
)
|
|
267
|
+
item = d.get("item_id") or "?"
|
|
268
|
+
ans = (d.get("decision") or "").strip().replace("\n", " ")
|
|
269
|
+
# Cap each answer so a pathological Telegram reply
|
|
270
|
+
# doesn't bloat the prompt past the model's context.
|
|
271
|
+
if len(ans) > 160:
|
|
272
|
+
ans = ans[:157] + "..."
|
|
273
|
+
ctx = (d.get("context") or "").strip().replace("\n", " ")
|
|
274
|
+
# Same cap rationale as answers; the prompt has many
|
|
275
|
+
# entries and at-scale this is the bigger context risk.
|
|
276
|
+
if len(ctx) > 240:
|
|
277
|
+
ctx = ctx[:237] + "..."
|
|
278
|
+
if ctx:
|
|
279
|
+
lines.append(
|
|
280
|
+
f'- {date} {item}: operator said "{ans}" '
|
|
281
|
+
f"(prior question: {ctx})"
|
|
282
|
+
)
|
|
283
|
+
else:
|
|
284
|
+
lines.append(
|
|
285
|
+
f'- {date} {item}: operator said "{ans}"'
|
|
286
|
+
)
|
|
287
|
+
lines.append("")
|
|
288
|
+
lines.append(
|
|
289
|
+
"**Use these to avoid re-asking.** If a Dependabot PR "
|
|
290
|
+
"matches an item above and circumstances are unchanged "
|
|
291
|
+
"(same package, version range still applies, CI state "
|
|
292
|
+
"still the same — compare the current PR against the "
|
|
293
|
+
"prior-question snippet above), act per the prior "
|
|
294
|
+
"decision without signalling BLOCKED. Only re-ASK if "
|
|
295
|
+
"the situation has materially changed (different "
|
|
296
|
+
"version bump, CI flipped red, new conflicts) — and "
|
|
297
|
+
"quote what changed in the question."
|
|
298
|
+
)
|
|
299
|
+
prior_decisions_block = "\n".join(lines) + "\n\n"
|
|
300
|
+
else:
|
|
301
|
+
prior_decisions_block = ""
|
|
302
|
+
|
|
303
|
+
return f"""Execute security operations for repository {repo}.
|
|
304
|
+
|
|
305
|
+
1. Check Dependabot alerts:
|
|
306
|
+
`gh api repos/{repo}/dependabot/alerts --jq '.[] | select(.state=="open")'`
|
|
307
|
+
2. Check Dependabot-authored security PRs:
|
|
308
|
+
`gh pr list --repo {repo} --author "app/dependabot" --json number,title`
|
|
309
|
+
3. Determine the trusted operator's GitHub login (the identity the
|
|
310
|
+
agent itself runs as). The auto-merge carve-out below applies ONLY
|
|
311
|
+
to PRs authored by this exact login — never to other contributors,
|
|
312
|
+
apps, or bots even if their PR looks safe:
|
|
313
|
+
`OPERATOR=$(gh api user --jq '.login')`
|
|
314
|
+
4. Check open PRs authored by the trusted operator that enable
|
|
315
|
+
Dependabot itself. These are prerequisites for future Dependabot
|
|
316
|
+
work and block the security pipeline if left open:
|
|
317
|
+
`gh pr list --repo {repo} --state open --author "$OPERATOR" --json number,title,files`
|
|
318
|
+
For each, inspect files: `gh pr view <NUM> --repo {repo} --json files`
|
|
319
|
+
5. For each alert or PR:
|
|
320
|
+
- **Dependabot PRs**: classify the update as patch/minor/major and
|
|
321
|
+
apply the per-repo policy below. If the severity is unclear, signal
|
|
322
|
+
BLOCKED to ask for guidance.
|
|
323
|
+
|
|
324
|
+
{dependabot_policy_block}
|
|
325
|
+
|
|
326
|
+
{prior_decisions_block}**Edge cases that override the policy ladder above:**
|
|
327
|
+
|
|
328
|
+
- **No CI configured** (repo has no `.github/workflows/*.yml`): the
|
|
329
|
+
"passing CI" gate cannot be evaluated, so an "auto" policy cannot
|
|
330
|
+
fire safely. Treat ALL open Dependabot PRs as ASK regardless of
|
|
331
|
+
their tier — signal BLOCKED ONCE for the whole repo with a single
|
|
332
|
+
consolidated question naming every open PR, e.g.: "Repo has no CI;
|
|
333
|
+
approve merging: #N (pkg X -> Y, patch), #M (pkg A -> B, minor)?"
|
|
334
|
+
Do not ask one BLOCKED per PR — operators get one consolidated
|
|
335
|
+
question per repo per sweep.
|
|
336
|
+
|
|
337
|
+
- **CI failing on a check UNRELATED to the PR's own package** (e.g.
|
|
338
|
+
pip-audit fails on a transitive CVE in `idna` while the PR bumps
|
|
339
|
+
`boto3`): do NOT silently report this and move on — that's how
|
|
340
|
+
patch PRs end up stuck for weeks. Signal BLOCKED with a one-line
|
|
341
|
+
question naming the underlying issue: "Patch PRs blocked by
|
|
342
|
+
pre-existing pip-audit failure on idna CVE-2026-... in lockfile.
|
|
343
|
+
Fix idna >= 3.15 first, then re-evaluate #117 and #119?" Include
|
|
344
|
+
the failing check name and a one-line root-cause hint extracted
|
|
345
|
+
from `gh run view --log-failed`.
|
|
346
|
+
|
|
347
|
+
**Scope discipline:**
|
|
348
|
+
|
|
349
|
+
You have exactly three legitimate exits for each open PR:
|
|
350
|
+
1. Merge it (per policy or per prior decision).
|
|
351
|
+
2. Leave it open (per policy or per prior decision).
|
|
352
|
+
3. Signal BLOCKED with a one-line policy question.
|
|
353
|
+
|
|
354
|
+
Do NOT propose alternative actions outside this ladder. In
|
|
355
|
+
particular, do NOT offer to: batch multiple Dependabot PRs into a
|
|
356
|
+
single review PR, open a tracking issue for the operator, perform a
|
|
357
|
+
manual code review of the bump, or any other freelance option. If
|
|
358
|
+
the right action isn't clear from policy + prior decisions + edge
|
|
359
|
+
cases above, ASK with a one-line question — don't invent.
|
|
360
|
+
|
|
361
|
+
Every BLOCKED question must contain ONLY items where the operator's
|
|
362
|
+
answer changes what you do next. Never pad a question with items you
|
|
363
|
+
have already decided — no "confirm no action wanted", no "left open
|
|
364
|
+
per policy" tails, no FYI clauses. Those belong in the step-6
|
|
365
|
+
summary. If nothing in the repo needs a decision, do not signal
|
|
366
|
+
BLOCKED at all: finish DONE and say what you left alone.
|
|
367
|
+
|
|
368
|
+
- **PR authored by `$OPERATOR` touching ONLY `.github/dependabot.yml`**
|
|
369
|
+
(additive ecosystem entries, no other files changed): MAYBE
|
|
370
|
+
auto-merge — but only after diff validation. These are the
|
|
371
|
+
"enable Dependabot" prerequisites the operator opened.
|
|
372
|
+
|
|
373
|
+
Required validation BEFORE merging:
|
|
374
|
+
a. **Author check**: confirm `author.login` exactly equals
|
|
375
|
+
`$OPERATOR`. Collaborators, GitHub apps, or external
|
|
376
|
+
contributors can craft a `dependabot.yml`-only PR to slip
|
|
377
|
+
past review — those go to BLOCKED, not auto-merge.
|
|
378
|
+
b. **Diff check**: pull the diff and confirm it is PURELY
|
|
379
|
+
ADDITIVE — no `-` lines (deletions or modifications of
|
|
380
|
+
existing stanzas), only `+` lines that introduce new
|
|
381
|
+
`package-ecosystem` blocks. Run:
|
|
382
|
+
`gh pr diff <NUM> --repo {repo}`
|
|
383
|
+
If any line in the diff begins with `-` (other than `---`
|
|
384
|
+
file headers), the PR modifies or removes existing config:
|
|
385
|
+
signal BLOCKED with a one-line summary like "operator
|
|
386
|
+
dependabot.yml PR #N modifies existing config — needs
|
|
387
|
+
review". Do NOT auto-merge.
|
|
388
|
+
c. **CI check**: confirm all status checks pass (matches the
|
|
389
|
+
Dependabot PR rule above). Run:
|
|
390
|
+
`gh pr checks <NUM> --repo {repo}`
|
|
391
|
+
Any failing or pending check -> BLOCKED. A PR that adds
|
|
392
|
+
invalid dependabot YAML can pass author+diff but fail
|
|
393
|
+
repo validation; without this gate the agent could merge
|
|
394
|
+
broken config and break Dependabot for the repo.
|
|
395
|
+
|
|
396
|
+
If (a), (b), AND (c) all pass: merge with squash, delete the
|
|
397
|
+
branch. If any one fails: BLOCKED.
|
|
398
|
+
- **Any other PR from `$OPERATOR`** (touches code, tests, configs
|
|
399
|
+
other than dependabot.yml): signal BLOCKED for operator approval.
|
|
400
|
+
Never auto-merge code changes, even from the trusted operator.
|
|
401
|
+
- **PRs from anyone else** (collaborators, contributors, other bots):
|
|
402
|
+
out of scope — see the PR scope fence below. Do not fetch them, do
|
|
403
|
+
not evaluate them, do not name them, do not signal BLOCKED on them.
|
|
404
|
+
6. Summarize actions taken. Report only what you acted on or decided
|
|
405
|
+
under the queries in steps 1-4. Do not report counts or descriptions
|
|
406
|
+
of PRs outside that scope.
|
|
407
|
+
|
|
408
|
+
**PR scope fence:**
|
|
409
|
+
|
|
410
|
+
The ONLY open PRs in scope are the ones returned by the two author-
|
|
411
|
+
filtered queries above: `--author "app/dependabot"` (step 2) and
|
|
412
|
+
`--author "$OPERATOR"` (step 4). That is the complete universe of PRs
|
|
413
|
+
this pipeline may look at.
|
|
414
|
+
|
|
415
|
+
Never run an unfiltered `gh pr list` (or any other broad PR/issue
|
|
416
|
+
search) to discover PRs beyond those two queries. A third party's open
|
|
417
|
+
PR is not this pipeline's business: it cannot be merged here, it needs
|
|
418
|
+
no decision here, and surfacing it only produces noise. If you catch
|
|
419
|
+
yourself about to write a phrase like "no open PRs at all" or "N open
|
|
420
|
+
PRs total", you have already stepped outside the fence — you can only
|
|
421
|
+
speak to the author-filtered results.
|
|
422
|
+
|
|
423
|
+
## Signaling Completion
|
|
424
|
+
|
|
425
|
+
**CRITICAL**: Before exiting, you MUST write a checkpoint file to signal completion.
|
|
426
|
+
|
|
427
|
+
STATE_FILE: {state_file_path}
|
|
428
|
+
SESSION_ID: {session_id}
|
|
429
|
+
|
|
430
|
+
**DONE** (completed):
|
|
431
|
+
```bash
|
|
432
|
+
mkdir -p "$(dirname '{state_file_path}')"
|
|
433
|
+
printf '{{"version":"1","status":"DONE","session_id":"{session_id}",'\
|
|
434
|
+
'"timestamp":"%s","summary":"%s"}}' \\
|
|
435
|
+
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<SUMMARY>" > '{state_file_path}'
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
**BLOCKED** (need input):
|
|
439
|
+
```bash
|
|
440
|
+
mkdir -p "$(dirname '{state_file_path}')"
|
|
441
|
+
printf '{{"version":"1","status":"BLOCKED_NEEDS_INPUT",'\
|
|
442
|
+
'"session_id":"{session_id}","timestamp":"%s","question":"%s"}}' \\
|
|
443
|
+
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<QUESTION>" > '{state_file_path}'
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
**FAILED**:
|
|
447
|
+
```bash
|
|
448
|
+
mkdir -p "$(dirname '{state_file_path}')"
|
|
449
|
+
printf '{{"version":"1","status":"FAILED","session_id":"{session_id}",'\
|
|
450
|
+
'"timestamp":"%s","error":"%s"}}' \\
|
|
451
|
+
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<ERROR>" > '{state_file_path}'
|
|
452
|
+
```"""
|
|
453
|
+
|
|
454
|
+
def _session_to_result(self, result: SessionResult) -> PipelineResult:
|
|
455
|
+
"""Convert SessionResult to PipelineResult."""
|
|
456
|
+
if result.state is None:
|
|
457
|
+
return PipelineResult(
|
|
458
|
+
success=False,
|
|
459
|
+
session_id=result.session_id,
|
|
460
|
+
summary="No checkpoint state returned",
|
|
461
|
+
error=result.stderr or "Unknown error",
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
if result.state.status == CheckpointStatus.DONE:
|
|
465
|
+
return PipelineResult(
|
|
466
|
+
success=True,
|
|
467
|
+
session_id=result.session_id,
|
|
468
|
+
summary=result.state.summary or "Completed",
|
|
469
|
+
outputs=result.state.outputs,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
if result.state.status == CheckpointStatus.BLOCKED_NEEDS_INPUT:
|
|
473
|
+
return PipelineResult(
|
|
474
|
+
success=False,
|
|
475
|
+
session_id=result.session_id,
|
|
476
|
+
summary="Blocked on user input",
|
|
477
|
+
blocked=True,
|
|
478
|
+
question=result.state.question,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
return PipelineResult(
|
|
482
|
+
success=False,
|
|
483
|
+
session_id=result.session_id,
|
|
484
|
+
summary="Failed",
|
|
485
|
+
error=result.state.error,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
async def run_secops_all(
|
|
490
|
+
repos: list[Any],
|
|
491
|
+
dispatcher: AgentAdapter,
|
|
492
|
+
github: GitHubCLI,
|
|
493
|
+
worktree: WorktreeManager,
|
|
494
|
+
dashboard: DashboardClient | None,
|
|
495
|
+
state_db: StateDB,
|
|
496
|
+
transport: Transport | None,
|
|
497
|
+
contexts_dir: Path,
|
|
498
|
+
max_blocked_rounds: int = DEFAULT_MAX_BLOCKED_ROUNDS,
|
|
499
|
+
) -> list[PipelineResult]:
|
|
500
|
+
"""Run secops pipeline on all configured repos."""
|
|
501
|
+
results = []
|
|
502
|
+
|
|
503
|
+
pipeline = SecopsPipeline(
|
|
504
|
+
dispatcher=dispatcher,
|
|
505
|
+
github=github,
|
|
506
|
+
worktree=worktree,
|
|
507
|
+
dashboard=dashboard,
|
|
508
|
+
state_db=state_db,
|
|
509
|
+
transport=transport,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
for repo_config in repos:
|
|
513
|
+
repo = repo_config.name
|
|
514
|
+
session_id = f"secops-{repo.replace('/', '-')}-{uuid.uuid4().hex[:8]}"
|
|
515
|
+
|
|
516
|
+
if not state_db.acquire_lock(repo, session_id):
|
|
517
|
+
results.append(PipelineResult(
|
|
518
|
+
success=False,
|
|
519
|
+
session_id=session_id,
|
|
520
|
+
summary=f"Could not acquire lock for {repo}",
|
|
521
|
+
error="Repository locked by another session",
|
|
522
|
+
))
|
|
523
|
+
continue
|
|
524
|
+
|
|
525
|
+
worktree_path: Path | None = None
|
|
526
|
+
session_row_inserted = False
|
|
527
|
+
session_final_state_written = False
|
|
528
|
+
try:
|
|
529
|
+
await worktree.ensure_bare_repo(repo)
|
|
530
|
+
worktree_path = await worktree.create_worktree(repo, session_id)
|
|
531
|
+
|
|
532
|
+
context_path = contexts_dir / repo.replace("/", "-") / "CLAUDE.md"
|
|
533
|
+
if context_path.exists():
|
|
534
|
+
worktree.symlink_context(worktree_path, context_path)
|
|
535
|
+
|
|
536
|
+
state_file = worktree_path / ".ctrlrelay" / "state.json"
|
|
537
|
+
state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
538
|
+
|
|
539
|
+
# Pull the 30-day rolling window of prior operator decisions
|
|
540
|
+
# so the prompt can include them. Best-effort: if the lookup
|
|
541
|
+
# fails for any reason, fall through with an empty list —
|
|
542
|
+
# losing this just means the next sweep re-asks, which is the
|
|
543
|
+
# pre-fix status quo, not a regression.
|
|
544
|
+
try:
|
|
545
|
+
prior_decisions = state_db.list_recent_automation_decisions(
|
|
546
|
+
repo,
|
|
547
|
+
operation="dependabot_pr",
|
|
548
|
+
since_ts=int(time.time()) - DECISION_RECALL_SECONDS,
|
|
549
|
+
)
|
|
550
|
+
except Exception as e:
|
|
551
|
+
log_event(
|
|
552
|
+
_logger,
|
|
553
|
+
"secops.prior_decisions.lookup_failed",
|
|
554
|
+
session_id=session_id,
|
|
555
|
+
repo=repo,
|
|
556
|
+
error_type=type(e).__name__,
|
|
557
|
+
error=str(e)[:200],
|
|
558
|
+
)
|
|
559
|
+
prior_decisions = []
|
|
560
|
+
|
|
561
|
+
ctx = PipelineContext(
|
|
562
|
+
session_id=session_id,
|
|
563
|
+
repo=repo,
|
|
564
|
+
worktree_path=worktree_path,
|
|
565
|
+
context_path=context_path,
|
|
566
|
+
state_file=state_file,
|
|
567
|
+
extra={
|
|
568
|
+
"automation": getattr(repo_config, "automation", None),
|
|
569
|
+
"prior_decisions": prior_decisions,
|
|
570
|
+
},
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
state_db.execute(
|
|
574
|
+
"""INSERT INTO sessions (id, pipeline, repo, worktree_path, status, started_at)
|
|
575
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
576
|
+
(session_id, "secops", repo, str(worktree_path), "running", int(time.time())),
|
|
577
|
+
)
|
|
578
|
+
state_db.commit()
|
|
579
|
+
session_row_inserted = True
|
|
580
|
+
|
|
581
|
+
result = await pipeline.run(ctx)
|
|
582
|
+
|
|
583
|
+
# BLOCKED loop: when the agent ends BLOCKED_NEEDS_INPUT, push
|
|
584
|
+
# the question to the operator via the configured transport
|
|
585
|
+
# (Telegram), wait for an answer, and resume. Mirrors the
|
|
586
|
+
# dev/task pipelines — without this, secops blocked sessions
|
|
587
|
+
# silently land as "blocked" in the DB without ever reaching
|
|
588
|
+
# the operator. transport.ask() failure preserves blocked=True
|
|
589
|
+
# so the persistence-on-blocked branch below still fires and
|
|
590
|
+
# writes a pending_resumes row that a later out-of-band reply
|
|
591
|
+
# could resume via the sweeper.
|
|
592
|
+
rounds = 0
|
|
593
|
+
while (
|
|
594
|
+
result.blocked
|
|
595
|
+
and transport is not None
|
|
596
|
+
and rounds < max_blocked_rounds
|
|
597
|
+
):
|
|
598
|
+
question = _question_for_persist(session_id, result)
|
|
599
|
+
try:
|
|
600
|
+
answer = await transport.ask(
|
|
601
|
+
question,
|
|
602
|
+
session_id=session_id,
|
|
603
|
+
repo=repo,
|
|
604
|
+
)
|
|
605
|
+
except Exception as e:
|
|
606
|
+
log_event(
|
|
607
|
+
_logger,
|
|
608
|
+
"secops.transport.ask_failed",
|
|
609
|
+
session_id=session_id,
|
|
610
|
+
repo=repo,
|
|
611
|
+
error_type=type(e).__name__,
|
|
612
|
+
error=str(e)[:200],
|
|
613
|
+
)
|
|
614
|
+
result = PipelineResult(
|
|
615
|
+
success=False,
|
|
616
|
+
blocked=True,
|
|
617
|
+
session_id=session_id,
|
|
618
|
+
summary=f"Blocked session deferred (transport): {e}",
|
|
619
|
+
error=str(e),
|
|
620
|
+
question=question,
|
|
621
|
+
outputs=result.outputs,
|
|
622
|
+
)
|
|
623
|
+
break
|
|
624
|
+
_record_decisions_from_answer(
|
|
625
|
+
state_db,
|
|
626
|
+
repo=repo,
|
|
627
|
+
question=question,
|
|
628
|
+
answer=answer,
|
|
629
|
+
session_id=session_id,
|
|
630
|
+
)
|
|
631
|
+
rounds += 1
|
|
632
|
+
result = await pipeline.resume(ctx, answer)
|
|
633
|
+
|
|
634
|
+
results.append(result)
|
|
635
|
+
|
|
636
|
+
status = "done" if result.success else ("blocked" if result.blocked else "failed")
|
|
637
|
+
state_db.execute(
|
|
638
|
+
"UPDATE sessions SET status = ?, summary = ?, ended_at = ? WHERE id = ?",
|
|
639
|
+
(status, result.summary, int(time.time()), session_id),
|
|
640
|
+
)
|
|
641
|
+
state_db.commit()
|
|
642
|
+
session_final_state_written = True
|
|
643
|
+
|
|
644
|
+
# Persist BLOCKED so a Telegram reply arriving after this
|
|
645
|
+
# session tears down can still be routed to a resume. Without
|
|
646
|
+
# this, the bridge's in-memory `_pending_questions` dies with
|
|
647
|
+
# the ASK socket and the operator's answer lands in a void.
|
|
648
|
+
if result.blocked:
|
|
649
|
+
try:
|
|
650
|
+
state_db.add_pending_resume(
|
|
651
|
+
session_id=session_id,
|
|
652
|
+
pipeline="secops",
|
|
653
|
+
repo=repo,
|
|
654
|
+
question=_question_for_persist(session_id, result),
|
|
655
|
+
)
|
|
656
|
+
except Exception as e:
|
|
657
|
+
log_event(
|
|
658
|
+
_logger,
|
|
659
|
+
"secops.pending_resume.insert_failed",
|
|
660
|
+
session_id=session_id,
|
|
661
|
+
repo=repo,
|
|
662
|
+
error_type=type(e).__name__,
|
|
663
|
+
error=str(e)[:200],
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
if dashboard and result.success:
|
|
667
|
+
await dashboard.push_event(EventPayload(
|
|
668
|
+
level="info",
|
|
669
|
+
pipeline="secops",
|
|
670
|
+
repo=repo,
|
|
671
|
+
message=result.summary,
|
|
672
|
+
session_id=session_id,
|
|
673
|
+
))
|
|
674
|
+
|
|
675
|
+
except asyncio.CancelledError:
|
|
676
|
+
# Scheduled secops interrupted mid-run (SIGTERM during a
|
|
677
|
+
# scheduler.shutdown). Mark the session as cancelled so later
|
|
678
|
+
# inspection doesn't see a phantom "running" row — but ONLY
|
|
679
|
+
# if we hadn't already written a final state. Without this
|
|
680
|
+
# guard, a cancel landing during the post-run dashboard push
|
|
681
|
+
# would clobber a successful "done" status with "cancelled"
|
|
682
|
+
# even though the work completed.
|
|
683
|
+
if session_row_inserted and not session_final_state_written:
|
|
684
|
+
try:
|
|
685
|
+
state_db.execute(
|
|
686
|
+
"UPDATE sessions SET status = ?, summary = ?, "
|
|
687
|
+
"ended_at = ? WHERE id = ?",
|
|
688
|
+
(
|
|
689
|
+
"cancelled",
|
|
690
|
+
"Cancelled during shutdown",
|
|
691
|
+
int(time.time()),
|
|
692
|
+
session_id,
|
|
693
|
+
),
|
|
694
|
+
)
|
|
695
|
+
state_db.commit()
|
|
696
|
+
except Exception:
|
|
697
|
+
pass
|
|
698
|
+
raise
|
|
699
|
+
|
|
700
|
+
except Exception as e:
|
|
701
|
+
# Never let a repo fail silently. Two independent holes put
|
|
702
|
+
# the cause beyond reach for the operator:
|
|
703
|
+
# 1. No log_event here, so nothing reached poller.log.
|
|
704
|
+
# 2. The DB row is written only when the failure lands
|
|
705
|
+
# AFTER the INSERT — anything raised by ensure_bare_repo
|
|
706
|
+
# or create_worktree (both network-bound, both before
|
|
707
|
+
# it) left no row at all.
|
|
708
|
+
# With an exception whose str() is empty (a bare
|
|
709
|
+
# asyncio.TimeoutError) the Telegram alert then fell back to
|
|
710
|
+
# the generic summary and named only the repo. Log first, so
|
|
711
|
+
# the cause survives regardless of which branch runs next.
|
|
712
|
+
detail = str(e) or type(e).__name__
|
|
713
|
+
# This is the last-resort handler for the whole repo, and
|
|
714
|
+
# every repo after this one in the sweep depends on it
|
|
715
|
+
# returning normally. A raising logger here would abort the
|
|
716
|
+
# remaining repos AND destroy the original exception —
|
|
717
|
+
# trading one repo's failure for the entire pass. Losing the
|
|
718
|
+
# log line is the strictly better failure.
|
|
719
|
+
try:
|
|
720
|
+
log_event(
|
|
721
|
+
_logger,
|
|
722
|
+
"secops.repo.failed",
|
|
723
|
+
session_id=session_id,
|
|
724
|
+
repo=repo,
|
|
725
|
+
session_row_inserted=session_row_inserted,
|
|
726
|
+
error_type=type(e).__name__,
|
|
727
|
+
error=detail[:200],
|
|
728
|
+
)
|
|
729
|
+
except Exception:
|
|
730
|
+
pass
|
|
731
|
+
if session_row_inserted:
|
|
732
|
+
state_db.execute(
|
|
733
|
+
"UPDATE sessions SET status = ?, summary = ?, "
|
|
734
|
+
"ended_at = ? WHERE id = ?",
|
|
735
|
+
("failed", f"Error: {detail}", int(time.time()), session_id),
|
|
736
|
+
)
|
|
737
|
+
state_db.commit()
|
|
738
|
+
results.append(PipelineResult(
|
|
739
|
+
success=False,
|
|
740
|
+
session_id=session_id,
|
|
741
|
+
summary=f"Error processing {repo}: {type(e).__name__}: {detail}",
|
|
742
|
+
# `str(e)` alone can be empty; the notifier picks
|
|
743
|
+
# `result.error or result.summary`, so an empty error
|
|
744
|
+
# silently degraded the alert to the bare summary.
|
|
745
|
+
error=f"{type(e).__name__}: {detail}",
|
|
746
|
+
))
|
|
747
|
+
|
|
748
|
+
finally:
|
|
749
|
+
# Hold the per-repo lock THROUGH worktree cleanup so a
|
|
750
|
+
# concurrent dev/secops run can't acquire the same repo and
|
|
751
|
+
# race `git worktree prune` on the shared bare clone.
|
|
752
|
+
# Cleanup is bounded by an asyncio.wait_for timeout so a
|
|
753
|
+
# truly-stuck operation can't pin the lock forever — once
|
|
754
|
+
# the timeout fires we release anyway, preferring "possibly
|
|
755
|
+
# leaked git admin state (recoverable via `git worktree
|
|
756
|
+
# prune`)" over "repo locked across daemon restart".
|
|
757
|
+
#
|
|
758
|
+
# No asyncio.shield here: `_run_git` kills its subprocess
|
|
759
|
+
# synchronously on CancelledError so unshielded cleanup
|
|
760
|
+
# unwinds fast and without leaking a stray git. Previously
|
|
761
|
+
# the shield made the outer task cancel immediately while
|
|
762
|
+
# the inner cleanup kept running untracked — leaking state.
|
|
763
|
+
if worktree_path is not None:
|
|
764
|
+
try:
|
|
765
|
+
worktree.remove_context_symlink(worktree_path)
|
|
766
|
+
except Exception as cleanup_exc:
|
|
767
|
+
log_event(
|
|
768
|
+
_logger,
|
|
769
|
+
"secops.cleanup.symlink_failed",
|
|
770
|
+
session_id=session_id,
|
|
771
|
+
repo=repo,
|
|
772
|
+
error_type=type(cleanup_exc).__name__,
|
|
773
|
+
error=str(cleanup_exc)[:200],
|
|
774
|
+
)
|
|
775
|
+
try:
|
|
776
|
+
# Timeout matches Scheduler's cancel budget (150s) — a
|
|
777
|
+
# full worktree prune can take up to 120s per
|
|
778
|
+
# WorktreeManager._run_git ceiling.
|
|
779
|
+
await asyncio.wait_for(
|
|
780
|
+
worktree.remove_worktree(repo, session_id),
|
|
781
|
+
timeout=130.0,
|
|
782
|
+
)
|
|
783
|
+
except asyncio.TimeoutError:
|
|
784
|
+
log_event(
|
|
785
|
+
_logger,
|
|
786
|
+
"secops.cleanup.worktree_timeout",
|
|
787
|
+
session_id=session_id,
|
|
788
|
+
repo=repo,
|
|
789
|
+
worktree_path=str(worktree_path),
|
|
790
|
+
)
|
|
791
|
+
except asyncio.CancelledError:
|
|
792
|
+
log_event(
|
|
793
|
+
_logger,
|
|
794
|
+
"secops.cleanup.worktree_cancelled_mid_shutdown",
|
|
795
|
+
session_id=session_id,
|
|
796
|
+
repo=repo,
|
|
797
|
+
worktree_path=str(worktree_path),
|
|
798
|
+
)
|
|
799
|
+
# Release the lock before propagating — leaving it
|
|
800
|
+
# held across a daemon restart is worse than the
|
|
801
|
+
# small race with a future scheduled run (the dev
|
|
802
|
+
# handler retries on lock conflict anyway).
|
|
803
|
+
try:
|
|
804
|
+
state_db.release_lock(repo, session_id)
|
|
805
|
+
except Exception:
|
|
806
|
+
pass
|
|
807
|
+
raise
|
|
808
|
+
except Exception as cleanup_exc:
|
|
809
|
+
log_event(
|
|
810
|
+
_logger,
|
|
811
|
+
"secops.cleanup.worktree_failed",
|
|
812
|
+
session_id=session_id,
|
|
813
|
+
repo=repo,
|
|
814
|
+
worktree_path=str(worktree_path),
|
|
815
|
+
error_type=type(cleanup_exc).__name__,
|
|
816
|
+
error=str(cleanup_exc)[:200],
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
# Release the lock AFTER cleanup attempt so new runs don't
|
|
820
|
+
# race the prune. In the cancel path above we released early
|
|
821
|
+
# and re-raised; here we handle the non-cancel paths.
|
|
822
|
+
try:
|
|
823
|
+
state_db.release_lock(repo, session_id)
|
|
824
|
+
except Exception as lock_exc:
|
|
825
|
+
log_event(
|
|
826
|
+
_logger,
|
|
827
|
+
"secops.cleanup.lock_release_failed",
|
|
828
|
+
session_id=session_id,
|
|
829
|
+
repo=repo,
|
|
830
|
+
error_type=type(lock_exc).__name__,
|
|
831
|
+
error=str(lock_exc)[:200],
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
return results
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
async def resume_secops_from_pending(
|
|
838
|
+
session_id: str,
|
|
839
|
+
repo: str,
|
|
840
|
+
answer: str,
|
|
841
|
+
dispatcher: AgentAdapter,
|
|
842
|
+
github: GitHubCLI,
|
|
843
|
+
worktree: WorktreeManager,
|
|
844
|
+
dashboard: DashboardClient | None,
|
|
845
|
+
state_db: StateDB,
|
|
846
|
+
transport: Transport | None,
|
|
847
|
+
contexts_dir: Path,
|
|
848
|
+
automation: Any = None,
|
|
849
|
+
question: str | None = None,
|
|
850
|
+
) -> PipelineResult:
|
|
851
|
+
"""Resume a BLOCKED secops session using an answer that arrived via
|
|
852
|
+
Telegram after the original session had already torn down.
|
|
853
|
+
|
|
854
|
+
Mirrors ``run_secops_all``'s per-repo lifecycle (lock → worktree →
|
|
855
|
+
resume → cleanup) but targets a single session that's already got a
|
|
856
|
+
row in ``sessions`` (status='blocked') and a ``pending_resumes`` row
|
|
857
|
+
ready for ``mark_pending_resume_resumed``. Caller is responsible for
|
|
858
|
+
flipping the pending_resumes row after this returns.
|
|
859
|
+
|
|
860
|
+
``question`` is the original BLOCKED question (carried in the
|
|
861
|
+
pending_resumes row). When supplied, the operator answer is
|
|
862
|
+
persisted against any PR# the question mentioned so the next
|
|
863
|
+
sweep skips re-asking. Optional for backwards compat with older
|
|
864
|
+
callers that pre-date the persistence work.
|
|
865
|
+
"""
|
|
866
|
+
if question:
|
|
867
|
+
_record_decisions_from_answer(
|
|
868
|
+
state_db,
|
|
869
|
+
repo=repo,
|
|
870
|
+
question=question,
|
|
871
|
+
answer=answer,
|
|
872
|
+
session_id=session_id,
|
|
873
|
+
)
|
|
874
|
+
pipeline = SecopsPipeline(
|
|
875
|
+
dispatcher=dispatcher,
|
|
876
|
+
github=github,
|
|
877
|
+
worktree=worktree,
|
|
878
|
+
dashboard=dashboard,
|
|
879
|
+
state_db=state_db,
|
|
880
|
+
transport=transport,
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
if not state_db.acquire_lock(repo, session_id):
|
|
884
|
+
return PipelineResult(
|
|
885
|
+
success=False,
|
|
886
|
+
session_id=session_id,
|
|
887
|
+
summary=f"Could not acquire lock for {repo} to resume",
|
|
888
|
+
error="Repository locked by another session",
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
worktree_path: Path | None = None
|
|
892
|
+
try:
|
|
893
|
+
await worktree.ensure_bare_repo(repo)
|
|
894
|
+
worktree_path = await worktree.create_worktree(repo, session_id)
|
|
895
|
+
|
|
896
|
+
context_path = contexts_dir / repo.replace("/", "-") / "CLAUDE.md"
|
|
897
|
+
if context_path.exists():
|
|
898
|
+
worktree.symlink_context(worktree_path, context_path)
|
|
899
|
+
|
|
900
|
+
state_file = worktree_path / ".ctrlrelay" / "state.json"
|
|
901
|
+
state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
902
|
+
|
|
903
|
+
ctx = PipelineContext(
|
|
904
|
+
session_id=session_id,
|
|
905
|
+
repo=repo,
|
|
906
|
+
worktree_path=worktree_path,
|
|
907
|
+
context_path=context_path,
|
|
908
|
+
state_file=state_file,
|
|
909
|
+
extra={"automation": automation},
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
# Flip the session back to running so an observer sees progress,
|
|
913
|
+
# not a stale 'blocked' row while the resume is in flight.
|
|
914
|
+
state_db.execute(
|
|
915
|
+
"UPDATE sessions SET status = ?, ended_at = NULL WHERE id = ?",
|
|
916
|
+
("running", session_id),
|
|
917
|
+
)
|
|
918
|
+
state_db.commit()
|
|
919
|
+
|
|
920
|
+
result = await pipeline.resume(ctx, answer)
|
|
921
|
+
|
|
922
|
+
status = "done" if result.success else (
|
|
923
|
+
"blocked" if result.blocked else "failed"
|
|
924
|
+
)
|
|
925
|
+
state_db.execute(
|
|
926
|
+
"UPDATE sessions SET status = ?, summary = ?, ended_at = ? "
|
|
927
|
+
"WHERE id = ?",
|
|
928
|
+
(status, result.summary, int(time.time()), session_id),
|
|
929
|
+
)
|
|
930
|
+
state_db.commit()
|
|
931
|
+
|
|
932
|
+
# If the resume re-blocked (operator answer was ambiguous, agent
|
|
933
|
+
# needs more), refresh the pending_resumes row so a new reply
|
|
934
|
+
# routes to this same session again.
|
|
935
|
+
if result.blocked:
|
|
936
|
+
try:
|
|
937
|
+
state_db.add_pending_resume(
|
|
938
|
+
session_id=session_id,
|
|
939
|
+
pipeline="secops",
|
|
940
|
+
repo=repo,
|
|
941
|
+
question=_question_for_persist(session_id, result),
|
|
942
|
+
)
|
|
943
|
+
except Exception as e:
|
|
944
|
+
log_event(
|
|
945
|
+
_logger,
|
|
946
|
+
"secops.pending_resume.reblock_insert_failed",
|
|
947
|
+
session_id=session_id,
|
|
948
|
+
repo=repo,
|
|
949
|
+
error_type=type(e).__name__,
|
|
950
|
+
error=str(e)[:200],
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
return result
|
|
954
|
+
|
|
955
|
+
except Exception as e:
|
|
956
|
+
state_db.execute(
|
|
957
|
+
"UPDATE sessions SET status = ?, summary = ?, ended_at = ? "
|
|
958
|
+
"WHERE id = ?",
|
|
959
|
+
("failed", f"Resume error: {e}", int(time.time()), session_id),
|
|
960
|
+
)
|
|
961
|
+
state_db.commit()
|
|
962
|
+
return PipelineResult(
|
|
963
|
+
success=False,
|
|
964
|
+
session_id=session_id,
|
|
965
|
+
summary=f"Error resuming {repo}",
|
|
966
|
+
error=str(e),
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
finally:
|
|
970
|
+
# Same cleanup pattern as run_secops_all: symlink → worktree →
|
|
971
|
+
# lock, bounded timeouts, log-don't-crash.
|
|
972
|
+
if worktree_path is not None:
|
|
973
|
+
try:
|
|
974
|
+
worktree.remove_context_symlink(worktree_path)
|
|
975
|
+
except Exception as cleanup_exc:
|
|
976
|
+
log_event(
|
|
977
|
+
_logger,
|
|
978
|
+
"secops.resume.cleanup.symlink_failed",
|
|
979
|
+
session_id=session_id,
|
|
980
|
+
repo=repo,
|
|
981
|
+
error_type=type(cleanup_exc).__name__,
|
|
982
|
+
error=str(cleanup_exc)[:200],
|
|
983
|
+
)
|
|
984
|
+
try:
|
|
985
|
+
await asyncio.wait_for(
|
|
986
|
+
worktree.remove_worktree(repo, session_id),
|
|
987
|
+
timeout=130.0,
|
|
988
|
+
)
|
|
989
|
+
except asyncio.TimeoutError:
|
|
990
|
+
log_event(
|
|
991
|
+
_logger,
|
|
992
|
+
"secops.resume.cleanup.worktree_timeout",
|
|
993
|
+
session_id=session_id,
|
|
994
|
+
repo=repo,
|
|
995
|
+
worktree_path=str(worktree_path),
|
|
996
|
+
)
|
|
997
|
+
except Exception as cleanup_exc:
|
|
998
|
+
log_event(
|
|
999
|
+
_logger,
|
|
1000
|
+
"secops.resume.cleanup.worktree_failed",
|
|
1001
|
+
session_id=session_id,
|
|
1002
|
+
repo=repo,
|
|
1003
|
+
worktree_path=str(worktree_path),
|
|
1004
|
+
error_type=type(cleanup_exc).__name__,
|
|
1005
|
+
error=str(cleanup_exc)[:200],
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
try:
|
|
1009
|
+
state_db.release_lock(repo, session_id)
|
|
1010
|
+
except Exception as lock_exc:
|
|
1011
|
+
log_event(
|
|
1012
|
+
_logger,
|
|
1013
|
+
"secops.resume.cleanup.lock_release_failed",
|
|
1014
|
+
session_id=session_id,
|
|
1015
|
+
repo=repo,
|
|
1016
|
+
error_type=type(lock_exc).__name__,
|
|
1017
|
+
error=str(lock_exc)[:200],
|
|
1018
|
+
)
|