ctrlrelay 0.1.5__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.
@@ -0,0 +1,279 @@
1
+ """Post-merge handling for dev pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any, Awaitable, Callable
7
+
8
+ from ctrlrelay.core.github import GitHubCLI
9
+ from ctrlrelay.core.obs import get_logger, log_event
10
+ from ctrlrelay.core.pr_watcher import PRWatcher
11
+ from ctrlrelay.transports.base import Transport
12
+
13
+ _logger = get_logger("pipelines.post_merge")
14
+
15
+ # Default merge-watch window. Real PR review cycles commonly exceed a
16
+ # day (weekends, time-zone splits, larger changes), and a silent watch
17
+ # timeout means the linked issue never auto-closes and no merge
18
+ # notification fires. 7 days is the sweet spot: covers typical review
19
+ # lag without keeping a zombie task alive for months on an abandoned PR.
20
+ # Operators can still override via an explicit `timeout=` argument.
21
+ DEFAULT_PR_WATCH_TIMEOUT = 7 * 24 * 60 * 60
22
+
23
+ # After a merge is detected, handle_merge can fail transiently on
24
+ # `gh issue close` or the transport.send (network blip, rate limit).
25
+ # Without retry the whole automation is wasted — PR merged, issue
26
+ # stays open forever. Retry with modest backoff; if all attempts fail,
27
+ # the last failure is logged via dev.pr.watch_failed.
28
+ _HANDLE_MERGE_RETRY_ATTEMPTS = 5
29
+ _HANDLE_MERGE_RETRY_BASE_DELAY = 5 # seconds; doubled each attempt
30
+
31
+
32
+ async def handle_merge(
33
+ repo: str,
34
+ pr_number: int,
35
+ issue_number: int,
36
+ github: GitHubCLI,
37
+ transport: Transport | None = None,
38
+ ) -> None:
39
+ """Handle post-merge actions."""
40
+ await github.close_issue(
41
+ repo,
42
+ issue_number,
43
+ f"Closed by PR #{pr_number}",
44
+ )
45
+
46
+ if transport:
47
+ await transport.send(
48
+ f"Issue #{issue_number} closed after PR #{pr_number} merged in {repo}"
49
+ )
50
+
51
+
52
+ async def watch_and_handle_merge(
53
+ repo: str,
54
+ pr_number: int,
55
+ issue_number: int,
56
+ github: GitHubCLI,
57
+ transport: Transport | None = None,
58
+ poll_interval: int = 60,
59
+ timeout: int = DEFAULT_PR_WATCH_TIMEOUT,
60
+ ) -> bool:
61
+ """Watch for PR merge and handle post-merge actions."""
62
+ watcher = PRWatcher(github=github, poll_interval=poll_interval)
63
+
64
+ merged = await watcher.wait_for_merge(repo, pr_number, timeout=timeout)
65
+
66
+ if merged:
67
+ await handle_merge(
68
+ repo=repo,
69
+ pr_number=pr_number,
70
+ issue_number=issue_number,
71
+ github=github,
72
+ transport=transport,
73
+ )
74
+ return True
75
+
76
+ return False
77
+
78
+
79
+ async def _handle_merge_with_retry(
80
+ *,
81
+ repo: str,
82
+ pr_number: int,
83
+ issue_number: int,
84
+ github: GitHubCLI,
85
+ transport_factory: Callable[[], Awaitable[Transport | None]] | None,
86
+ session_id: str | None,
87
+ ) -> None:
88
+ """Run the post-merge close + notify steps with per-step idempotent
89
+ retry, rebuilding the transport on each attempt so a bridge restart
90
+ mid-retry doesn't permanently break the notification.
91
+
92
+ Idempotency: ``github.close_issue`` is called at most once even
93
+ across retries — if it succeeds but the notification step later
94
+ fails, subsequent attempts skip the close so we don't post
95
+ duplicate "Closed by PR #N" comments.
96
+
97
+ Transport rebuild: ``transport_factory`` is invoked INSIDE each
98
+ attempt. If the bridge socket drops between the watcher start and
99
+ a merge several days later, or during a retry, the next attempt
100
+ gets a fresh connection.
101
+
102
+ On final exhaustion, raises the last exception; pr_watch_task's
103
+ outer handler logs it as dev.pr.watch_failed.
104
+ """
105
+ # Split the post-merge work into three independent steps with
106
+ # their own idempotency flags. close_issue() in the github layer
107
+ # is NOT atomic — it posts a comment then runs `gh issue close`.
108
+ # If the close fails after the comment succeeded, a retry that
109
+ # called close_issue again would post a DUPLICATE comment.
110
+ # Tracking each sub-step separately ensures every side-effect
111
+ # runs at most once across retries.
112
+ comment_posted = False
113
+ issue_closed = False
114
+ notification_sent = False
115
+ last_exc: Exception | None = None
116
+ delay = _HANDLE_MERGE_RETRY_BASE_DELAY
117
+ comment = f"Closed by PR #{pr_number}"
118
+ notification = (
119
+ f"Issue #{issue_number} closed after PR #{pr_number} merged in {repo}"
120
+ )
121
+
122
+ for attempt in range(1, _HANDLE_MERGE_RETRY_ATTEMPTS + 1):
123
+ transport: Transport | None = None
124
+ transport_build_error: Exception | None = None
125
+ if not notification_sent and transport_factory is not None:
126
+ try:
127
+ transport = await transport_factory()
128
+ except Exception as e:
129
+ # Factory raised — bridge likely transiently unavailable.
130
+ # Record the error so we can retry on the next attempt
131
+ # instead of silently dropping the notification.
132
+ transport_build_error = e
133
+ log_event(
134
+ _logger, "dev.pr.watch_transport_failed",
135
+ session_id=session_id, repo=repo,
136
+ issue_number=issue_number, pr_number=pr_number,
137
+ attempt=attempt,
138
+ reason=type(e).__name__, error=str(e)[:200],
139
+ )
140
+ transport = None
141
+ # Factory returned None legitimately (e.g. non-Telegram
142
+ # config or socket intentionally absent) → no notification
143
+ # channel is configured; do NOT retry. `transport_build_error`
144
+ # remains None, so the "done" check below returns cleanly.
145
+
146
+ try:
147
+ if not comment_posted:
148
+ await github.comment_on_issue(repo, issue_number, comment)
149
+ comment_posted = True
150
+ if not issue_closed:
151
+ await github._run_gh(
152
+ "issue", "close", str(issue_number), "--repo", repo,
153
+ )
154
+ issue_closed = True
155
+ if not notification_sent and transport is not None:
156
+ await transport.send(notification)
157
+ notification_sent = True
158
+
159
+ # Done when either: we sent the notification this round, OR
160
+ # the factory returned None meaning no channel was ever
161
+ # configured. Fall through to the retry path ONLY if the
162
+ # factory itself raised — that's a transient failure worth
163
+ # retrying so a brief bridge outage doesn't drop the
164
+ # notification permanently.
165
+ if notification_sent or transport_build_error is None:
166
+ return
167
+ raise transport_build_error
168
+ except asyncio.CancelledError:
169
+ raise
170
+ except Exception as e:
171
+ last_exc = e
172
+ log_event(
173
+ _logger, "dev.pr.handle_merge_retry",
174
+ session_id=session_id, repo=repo,
175
+ issue_number=issue_number, pr_number=pr_number,
176
+ attempt=attempt,
177
+ max_attempts=_HANDLE_MERGE_RETRY_ATTEMPTS,
178
+ comment_posted=comment_posted,
179
+ issue_closed=issue_closed,
180
+ notification_sent=notification_sent,
181
+ reason=type(e).__name__, error=str(e)[:200],
182
+ )
183
+ if attempt >= _HANDLE_MERGE_RETRY_ATTEMPTS:
184
+ break
185
+ await asyncio.sleep(delay)
186
+ delay = min(delay * 2, 300)
187
+ finally:
188
+ if transport is not None:
189
+ try:
190
+ await transport.close()
191
+ except Exception:
192
+ pass
193
+ assert last_exc is not None
194
+ raise last_exc
195
+
196
+
197
+ async def pr_watch_task(
198
+ *,
199
+ repo: str,
200
+ issue_number: int,
201
+ pr_url: str,
202
+ pr_number: int,
203
+ session_id: str | None,
204
+ github: GitHubCLI,
205
+ transport_factory: Callable[[], Awaitable[Transport | None]] | None = None,
206
+ poll_interval: int = 60,
207
+ timeout: int = DEFAULT_PR_WATCH_TIMEOUT,
208
+ ) -> dict[str, Any]:
209
+ """Background-safe wrapper around the merge watcher that emits
210
+ structured ``dev.pr.*`` events and manages a transport LAZILY —
211
+ the transport is only created AFTER the merge is detected, so a
212
+ bridge that's down or restarted during the multi-day watch window
213
+ doesn't leave us with a stale connection at notification time.
214
+
215
+ ``transport_factory`` is an async callable returning a connected
216
+ transport (or None). Called once, right before ``handle_merge``;
217
+ its result is closed immediately after the merge handler runs.
218
+ A raising factory is logged and skipped (merge detection proceeds
219
+ without the notification channel rather than aborting the close).
220
+
221
+ Returns a dict describing the outcome:
222
+ {"merged": bool, "timed_out": bool, "cancelled": bool, "failed": str|None}
223
+ """
224
+ outcome: dict[str, Any] = {
225
+ "merged": False, "timed_out": False,
226
+ "cancelled": False, "failed": None,
227
+ }
228
+
229
+ log_event(
230
+ _logger, "dev.pr.watching",
231
+ session_id=session_id, repo=repo, issue_number=issue_number,
232
+ pr_number=pr_number, pr_url=pr_url,
233
+ )
234
+ try:
235
+ watcher = PRWatcher(github=github, poll_interval=poll_interval)
236
+ merged = await watcher.wait_for_merge(
237
+ repo=repo, pr_number=pr_number, timeout=timeout,
238
+ )
239
+ # Record merge detection BEFORE running the post-merge handler,
240
+ # so an exhausted retry loop (e.g. permanent bridge outage) still
241
+ # leaves `outcome["merged"] = True`. The merge really did happen;
242
+ # only the cleanup chain is degraded, and `outcome["failed"]`
243
+ # already surfaces that via the except branch below.
244
+ outcome["merged"] = merged
245
+ outcome["timed_out"] = not merged
246
+ log_event(
247
+ _logger,
248
+ "dev.pr.merged" if merged else "dev.pr.watch_timeout",
249
+ session_id=session_id, repo=repo, issue_number=issue_number,
250
+ pr_number=pr_number,
251
+ )
252
+ if merged:
253
+ # Defer transport construction to inside the retry loop so
254
+ # each attempt gets a fresh connection — a bridge restart
255
+ # during the watch or between retries doesn't leave us
256
+ # hitting a dead socket forever.
257
+ await _handle_merge_with_retry(
258
+ repo=repo, pr_number=pr_number,
259
+ issue_number=issue_number, github=github,
260
+ transport_factory=transport_factory,
261
+ session_id=session_id,
262
+ )
263
+ except asyncio.CancelledError:
264
+ outcome["cancelled"] = True
265
+ log_event(
266
+ _logger, "dev.pr.watch_cancelled",
267
+ session_id=session_id, repo=repo, issue_number=issue_number,
268
+ pr_number=pr_number,
269
+ )
270
+ raise
271
+ except Exception as e:
272
+ outcome["failed"] = type(e).__name__
273
+ log_event(
274
+ _logger, "dev.pr.watch_failed",
275
+ session_id=session_id, repo=repo, issue_number=issue_number,
276
+ pr_number=pr_number,
277
+ reason=type(e).__name__, error=str(e)[:200],
278
+ )
279
+ return outcome
@@ -0,0 +1,379 @@
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.state import StateDB
17
+ from ctrlrelay.core.worktree import WorktreeManager
18
+ from ctrlrelay.dashboard.client import DashboardClient, EventPayload
19
+ from ctrlrelay.pipelines.base import PipelineContext, PipelineResult
20
+ from ctrlrelay.transports.base import Transport
21
+
22
+ _logger = get_logger("pipeline.secops")
23
+
24
+
25
+ @dataclass
26
+ class SecopsPipeline:
27
+ """Security operations pipeline for daily triage."""
28
+
29
+ dispatcher: AgentAdapter
30
+ github: GitHubCLI
31
+ worktree: WorktreeManager
32
+ dashboard: DashboardClient | None
33
+ state_db: StateDB
34
+ transport: Transport | None
35
+
36
+ name: str = "secops"
37
+
38
+ async def run(self, ctx: PipelineContext) -> PipelineResult:
39
+ """Run secops on a single repo."""
40
+ prompt = self._build_prompt(
41
+ ctx.repo,
42
+ session_id=ctx.session_id,
43
+ state_file=ctx.state_file,
44
+ )
45
+
46
+ result = await self.dispatcher.spawn_session(
47
+ session_id=ctx.session_id,
48
+ prompt=prompt,
49
+ working_dir=ctx.worktree_path,
50
+ state_file=ctx.state_file,
51
+ )
52
+
53
+ return self._session_to_result(result)
54
+
55
+ async def resume(self, ctx: PipelineContext, answer: str) -> PipelineResult:
56
+ """Resume blocked secops with user answer."""
57
+ prompt = f"User answered: {answer}\n\nContinue from where you left off."
58
+
59
+ log_event(
60
+ _logger,
61
+ "dev.session.resumed",
62
+ session_id=ctx.session_id,
63
+ repo=ctx.repo,
64
+ issue_number=ctx.issue_number,
65
+ pipeline=self.name,
66
+ resume_session_id=ctx.session_id,
67
+ answer_length=len(answer),
68
+ answer_hash=hash_text(answer),
69
+ )
70
+
71
+ result = await self.dispatcher.spawn_session(
72
+ session_id=ctx.session_id,
73
+ prompt=prompt,
74
+ working_dir=ctx.worktree_path,
75
+ state_file=ctx.state_file,
76
+ resume_session_id=ctx.session_id,
77
+ )
78
+
79
+ return self._session_to_result(result)
80
+
81
+ def _build_prompt(
82
+ self,
83
+ repo: str,
84
+ session_id: str = "",
85
+ state_file: Path | None = None,
86
+ ) -> str:
87
+ """Build the secops prompt."""
88
+ state_file_path = str(state_file) if state_file else "/tmp/state.json"
89
+
90
+ return f"""Execute security operations for repository {repo}.
91
+
92
+ 1. Check Dependabot alerts:
93
+ `gh api repos/{repo}/dependabot/alerts --jq '.[] | select(.state=="open")'`
94
+ 2. Check security PRs:
95
+ `gh pr list --repo {repo} --author "app/dependabot" --json number,title`
96
+ 3. For each alert or PR:
97
+ - Review the severity and impact
98
+ - If patch/minor update with passing CI, merge the PR
99
+ - If major or unclear, signal BLOCKED to ask for guidance
100
+ 4. Summarize actions taken
101
+
102
+ ## Signaling Completion
103
+
104
+ **CRITICAL**: Before exiting, you MUST write a checkpoint file to signal completion.
105
+
106
+ STATE_FILE: {state_file_path}
107
+ SESSION_ID: {session_id}
108
+
109
+ **DONE** (completed):
110
+ ```bash
111
+ mkdir -p "$(dirname '{state_file_path}')"
112
+ printf '{{"version":"1","status":"DONE","session_id":"{session_id}",'\
113
+ '"timestamp":"%s","summary":"%s"}}' \\
114
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<SUMMARY>" > '{state_file_path}'
115
+ ```
116
+
117
+ **BLOCKED** (need input):
118
+ ```bash
119
+ mkdir -p "$(dirname '{state_file_path}')"
120
+ printf '{{"version":"1","status":"BLOCKED_NEEDS_INPUT",'\
121
+ '"session_id":"{session_id}","timestamp":"%s","question":"%s"}}' \\
122
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<QUESTION>" > '{state_file_path}'
123
+ ```
124
+
125
+ **FAILED**:
126
+ ```bash
127
+ mkdir -p "$(dirname '{state_file_path}')"
128
+ printf '{{"version":"1","status":"FAILED","session_id":"{session_id}",'\
129
+ '"timestamp":"%s","error":"%s"}}' \\
130
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<ERROR>" > '{state_file_path}'
131
+ ```"""
132
+
133
+ def _session_to_result(self, result: SessionResult) -> PipelineResult:
134
+ """Convert SessionResult to PipelineResult."""
135
+ if result.state is None:
136
+ return PipelineResult(
137
+ success=False,
138
+ session_id=result.session_id,
139
+ summary="No checkpoint state returned",
140
+ error=result.stderr or "Unknown error",
141
+ )
142
+
143
+ if result.state.status == CheckpointStatus.DONE:
144
+ return PipelineResult(
145
+ success=True,
146
+ session_id=result.session_id,
147
+ summary=result.state.summary or "Completed",
148
+ outputs=result.state.outputs,
149
+ )
150
+
151
+ if result.state.status == CheckpointStatus.BLOCKED_NEEDS_INPUT:
152
+ return PipelineResult(
153
+ success=False,
154
+ session_id=result.session_id,
155
+ summary="Blocked on user input",
156
+ blocked=True,
157
+ question=result.state.question,
158
+ )
159
+
160
+ return PipelineResult(
161
+ success=False,
162
+ session_id=result.session_id,
163
+ summary="Failed",
164
+ error=result.state.error,
165
+ )
166
+
167
+
168
+ async def run_secops_all(
169
+ repos: list[Any],
170
+ dispatcher: AgentAdapter,
171
+ github: GitHubCLI,
172
+ worktree: WorktreeManager,
173
+ dashboard: DashboardClient | None,
174
+ state_db: StateDB,
175
+ transport: Transport | None,
176
+ contexts_dir: Path,
177
+ ) -> list[PipelineResult]:
178
+ """Run secops pipeline on all configured repos."""
179
+ results = []
180
+
181
+ pipeline = SecopsPipeline(
182
+ dispatcher=dispatcher,
183
+ github=github,
184
+ worktree=worktree,
185
+ dashboard=dashboard,
186
+ state_db=state_db,
187
+ transport=transport,
188
+ )
189
+
190
+ for repo_config in repos:
191
+ repo = repo_config.name
192
+ session_id = f"secops-{repo.replace('/', '-')}-{uuid.uuid4().hex[:8]}"
193
+
194
+ if not state_db.acquire_lock(repo, session_id):
195
+ results.append(PipelineResult(
196
+ success=False,
197
+ session_id=session_id,
198
+ summary=f"Could not acquire lock for {repo}",
199
+ error="Repository locked by another session",
200
+ ))
201
+ continue
202
+
203
+ worktree_path: Path | None = None
204
+ session_row_inserted = False
205
+ session_final_state_written = False
206
+ try:
207
+ await worktree.ensure_bare_repo(repo)
208
+ worktree_path = await worktree.create_worktree(repo, session_id)
209
+
210
+ context_path = contexts_dir / repo.replace("/", "-") / "CLAUDE.md"
211
+ if context_path.exists():
212
+ worktree.symlink_context(worktree_path, context_path)
213
+
214
+ state_file = worktree_path / ".ctrlrelay" / "state.json"
215
+ state_file.parent.mkdir(parents=True, exist_ok=True)
216
+
217
+ ctx = PipelineContext(
218
+ session_id=session_id,
219
+ repo=repo,
220
+ worktree_path=worktree_path,
221
+ context_path=context_path,
222
+ state_file=state_file,
223
+ )
224
+
225
+ state_db.execute(
226
+ """INSERT INTO sessions (id, pipeline, repo, worktree_path, status, started_at)
227
+ VALUES (?, ?, ?, ?, ?, ?)""",
228
+ (session_id, "secops", repo, str(worktree_path), "running", int(time.time())),
229
+ )
230
+ state_db.commit()
231
+ session_row_inserted = True
232
+
233
+ result = await pipeline.run(ctx)
234
+ results.append(result)
235
+
236
+ status = "done" if result.success else ("blocked" if result.blocked else "failed")
237
+ state_db.execute(
238
+ "UPDATE sessions SET status = ?, summary = ?, ended_at = ? WHERE id = ?",
239
+ (status, result.summary, int(time.time()), session_id),
240
+ )
241
+ state_db.commit()
242
+ session_final_state_written = True
243
+
244
+ if dashboard and result.success:
245
+ await dashboard.push_event(EventPayload(
246
+ level="info",
247
+ pipeline="secops",
248
+ repo=repo,
249
+ message=result.summary,
250
+ session_id=session_id,
251
+ ))
252
+
253
+ except asyncio.CancelledError:
254
+ # Scheduled secops interrupted mid-run (SIGTERM during a
255
+ # scheduler.shutdown). Mark the session as cancelled so later
256
+ # inspection doesn't see a phantom "running" row — but ONLY
257
+ # if we hadn't already written a final state. Without this
258
+ # guard, a cancel landing during the post-run dashboard push
259
+ # would clobber a successful "done" status with "cancelled"
260
+ # even though the work completed.
261
+ if session_row_inserted and not session_final_state_written:
262
+ try:
263
+ state_db.execute(
264
+ "UPDATE sessions SET status = ?, summary = ?, "
265
+ "ended_at = ? WHERE id = ?",
266
+ (
267
+ "cancelled",
268
+ "Cancelled during shutdown",
269
+ int(time.time()),
270
+ session_id,
271
+ ),
272
+ )
273
+ state_db.commit()
274
+ except Exception:
275
+ pass
276
+ raise
277
+
278
+ except Exception as e:
279
+ if session_row_inserted:
280
+ state_db.execute(
281
+ "UPDATE sessions SET status = ?, summary = ?, "
282
+ "ended_at = ? WHERE id = ?",
283
+ ("failed", f"Error: {e}", int(time.time()), session_id),
284
+ )
285
+ state_db.commit()
286
+ results.append(PipelineResult(
287
+ success=False,
288
+ session_id=session_id,
289
+ summary=f"Error processing {repo}",
290
+ error=str(e),
291
+ ))
292
+
293
+ finally:
294
+ # Hold the per-repo lock THROUGH worktree cleanup so a
295
+ # concurrent dev/secops run can't acquire the same repo and
296
+ # race `git worktree prune` on the shared bare clone.
297
+ # Cleanup is bounded by an asyncio.wait_for timeout so a
298
+ # truly-stuck operation can't pin the lock forever — once
299
+ # the timeout fires we release anyway, preferring "possibly
300
+ # leaked git admin state (recoverable via `git worktree
301
+ # prune`)" over "repo locked across daemon restart".
302
+ #
303
+ # No asyncio.shield here: `_run_git` kills its subprocess
304
+ # synchronously on CancelledError so unshielded cleanup
305
+ # unwinds fast and without leaking a stray git. Previously
306
+ # the shield made the outer task cancel immediately while
307
+ # the inner cleanup kept running untracked — leaking state.
308
+ if worktree_path is not None:
309
+ try:
310
+ worktree.remove_context_symlink(worktree_path)
311
+ except Exception as cleanup_exc:
312
+ log_event(
313
+ _logger,
314
+ "secops.cleanup.symlink_failed",
315
+ session_id=session_id,
316
+ repo=repo,
317
+ error_type=type(cleanup_exc).__name__,
318
+ error=str(cleanup_exc)[:200],
319
+ )
320
+ try:
321
+ # Timeout matches Scheduler's cancel budget (150s) — a
322
+ # full worktree prune can take up to 120s per
323
+ # WorktreeManager._run_git ceiling.
324
+ await asyncio.wait_for(
325
+ worktree.remove_worktree(repo, session_id),
326
+ timeout=130.0,
327
+ )
328
+ except asyncio.TimeoutError:
329
+ log_event(
330
+ _logger,
331
+ "secops.cleanup.worktree_timeout",
332
+ session_id=session_id,
333
+ repo=repo,
334
+ worktree_path=str(worktree_path),
335
+ )
336
+ except asyncio.CancelledError:
337
+ log_event(
338
+ _logger,
339
+ "secops.cleanup.worktree_cancelled_mid_shutdown",
340
+ session_id=session_id,
341
+ repo=repo,
342
+ worktree_path=str(worktree_path),
343
+ )
344
+ # Release the lock before propagating — leaving it
345
+ # held across a daemon restart is worse than the
346
+ # small race with a future scheduled run (the dev
347
+ # handler retries on lock conflict anyway).
348
+ try:
349
+ state_db.release_lock(repo, session_id)
350
+ except Exception:
351
+ pass
352
+ raise
353
+ except Exception as cleanup_exc:
354
+ log_event(
355
+ _logger,
356
+ "secops.cleanup.worktree_failed",
357
+ session_id=session_id,
358
+ repo=repo,
359
+ worktree_path=str(worktree_path),
360
+ error_type=type(cleanup_exc).__name__,
361
+ error=str(cleanup_exc)[:200],
362
+ )
363
+
364
+ # Release the lock AFTER cleanup attempt so new runs don't
365
+ # race the prune. In the cancel path above we released early
366
+ # and re-raised; here we handle the non-cancel paths.
367
+ try:
368
+ state_db.release_lock(repo, session_id)
369
+ except Exception as lock_exc:
370
+ log_event(
371
+ _logger,
372
+ "secops.cleanup.lock_release_failed",
373
+ session_id=session_id,
374
+ repo=repo,
375
+ error_type=type(lock_exc).__name__,
376
+ error=str(lock_exc)[:200],
377
+ )
378
+
379
+ return results
@@ -0,0 +1,33 @@
1
+ """Transport abstraction for orchestrator communication."""
2
+
3
+ from ctrlrelay.core.config import TransportConfig, TransportType
4
+ from ctrlrelay.transports.base import Transport, TransportError
5
+ from ctrlrelay.transports.file_mock import FileMockTransport
6
+ from ctrlrelay.transports.socket_client import SocketTransport
7
+
8
+
9
+ def get_transport(config: TransportConfig) -> Transport:
10
+ """Create transport instance from config."""
11
+ if config.type == TransportType.FILE_MOCK:
12
+ assert config.file_mock is not None
13
+ return FileMockTransport(
14
+ inbox=config.file_mock.inbox,
15
+ outbox=config.file_mock.outbox,
16
+ )
17
+
18
+ if config.type == TransportType.TELEGRAM:
19
+ assert config.telegram is not None
20
+ return SocketTransport(
21
+ socket_path=config.telegram.socket_path,
22
+ )
23
+
24
+ raise TransportError(f"Unknown transport type: {config.type}")
25
+
26
+
27
+ __all__ = [
28
+ "FileMockTransport",
29
+ "SocketTransport",
30
+ "Transport",
31
+ "TransportError",
32
+ "get_transport",
33
+ ]