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,562 @@
1
+ """Dev pipeline for issue-to-PR workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from ctrlrelay.core.checkpoint import CheckpointStatus
12
+ from ctrlrelay.core.dispatcher import AgentAdapter, SessionResult
13
+ from ctrlrelay.core.github import GitHubCLI
14
+ from ctrlrelay.core.obs import get_logger, hash_text, log_event
15
+ from ctrlrelay.core.pr_verifier import PRVerifier, VerificationResult
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
+ DEFAULT_MAX_FIX_ATTEMPTS = 3
23
+ DEFAULT_MAX_BLOCKED_ROUNDS = 5
24
+
25
+ AGENT_CLAIM_MARKER = "<!-- ctrlrelay:claimed -->"
26
+ AGENT_CLAIM_COMMENT = (
27
+ "CTRLRelay is working on this issue. A PR will be opened for review.\n\n"
28
+ f"{AGENT_CLAIM_MARKER}"
29
+ )
30
+
31
+ _logger = get_logger("pipeline.dev")
32
+
33
+
34
+ @dataclass
35
+ class DevPipeline:
36
+ """Dev pipeline for implementing issues and opening PRs."""
37
+
38
+ dispatcher: AgentAdapter
39
+ github: GitHubCLI
40
+ worktree: WorktreeManager
41
+ dashboard: DashboardClient | None
42
+ state_db: StateDB
43
+ transport: Transport | None
44
+
45
+ name: str = "dev"
46
+
47
+ async def run(self, ctx: PipelineContext) -> PipelineResult:
48
+ """Run dev pipeline on a single issue."""
49
+ prompt = self._build_prompt(
50
+ ctx.repo, ctx.issue_number, ctx.extra,
51
+ session_id=ctx.session_id,
52
+ state_file=ctx.state_file,
53
+ )
54
+
55
+ result = await self.dispatcher.spawn_session(
56
+ session_id=ctx.session_id,
57
+ prompt=prompt,
58
+ working_dir=ctx.worktree_path,
59
+ state_file=ctx.state_file,
60
+ )
61
+
62
+ return self._session_to_result(result)
63
+
64
+ async def resume(self, ctx: PipelineContext, answer: str) -> PipelineResult:
65
+ """Resume blocked dev session with user answer."""
66
+ prompt = f"User answered: {answer}\n\nContinue from where you left off."
67
+
68
+ log_event(
69
+ _logger,
70
+ "dev.session.resumed",
71
+ session_id=ctx.session_id,
72
+ repo=ctx.repo,
73
+ issue_number=ctx.issue_number,
74
+ pipeline=self.name,
75
+ resume_session_id=ctx.session_id,
76
+ answer_length=len(answer),
77
+ answer_hash=hash_text(answer),
78
+ )
79
+
80
+ result = await self.dispatcher.spawn_session(
81
+ session_id=ctx.session_id,
82
+ prompt=prompt,
83
+ working_dir=ctx.worktree_path,
84
+ state_file=ctx.state_file,
85
+ resume_session_id=ctx.session_id,
86
+ )
87
+
88
+ return self._session_to_result(result)
89
+
90
+ async def request_fix(
91
+ self, ctx: PipelineContext, fix_instructions: str
92
+ ) -> PipelineResult:
93
+ """Resume the session with a fix request (failing CI or merge conflict)."""
94
+ result = await self.dispatcher.spawn_session(
95
+ session_id=ctx.session_id,
96
+ prompt=fix_instructions,
97
+ working_dir=ctx.worktree_path,
98
+ state_file=ctx.state_file,
99
+ resume_session_id=ctx.session_id,
100
+ )
101
+ return self._session_to_result(result)
102
+
103
+ def _build_prompt(
104
+ self,
105
+ repo: str,
106
+ issue_number: int | None,
107
+ extra: dict[str, Any],
108
+ session_id: str = "",
109
+ state_file: Path | None = None,
110
+ ) -> str:
111
+ """Build the dev pipeline prompt."""
112
+ issue_title = extra.get("issue_title", "")
113
+ issue_body = extra.get("issue_body", "")
114
+ branch_name = extra.get("branch_name", "")
115
+ state_file_path = str(state_file) if state_file else "/tmp/state.json"
116
+
117
+ return f"""You are working on issue #{issue_number} in repository {repo}.
118
+
119
+ **Issue Title:** {issue_title}
120
+
121
+ **Issue Body:**
122
+ {issue_body}
123
+
124
+ **Branch:** {branch_name}
125
+
126
+ Execute the following workflow:
127
+
128
+ 1. Validate the issue still applies to the current codebase
129
+ 2. If anything is unclear, signal BLOCKED (see below) to ask for clarification
130
+ 3. Plan and implement the fix using TDD
131
+ 4. Push the branch and open a PR that references the issue
132
+ 5. Before signaling DONE, verify the PR is mergeable:
133
+ - Poll `gh pr checks <PR>` until every check is `completed`; if any
134
+ conclusion is `failure`/`cancelled`/`timed_out`, investigate, fix, push
135
+ again, and re-poll.
136
+ - Run `gh pr view <PR> --json mergeable,mergeStateStatus` — if `mergeable`
137
+ is `CONFLICTING` or `mergeStateStatus` is `DIRTY`, rebase onto the base
138
+ branch, resolve conflicts, and push again.
139
+ 6. Signal DONE only when the PR is green AND conflict-free, with the PR URL.
140
+
141
+ Do NOT merge the PR - wait for human review.
142
+
143
+ The orchestrator re-verifies CI and mergeability after you hand off; if it
144
+ finds the PR broken it will resume this session asking you to fix it.
145
+
146
+ ## Signaling Completion
147
+
148
+ **CRITICAL**: Before exiting, you MUST write a checkpoint file to signal completion.
149
+
150
+ STATE_FILE: {state_file_path}
151
+ SESSION_ID: {session_id}
152
+
153
+ **DONE** (PR opened AND verified green + conflict-free):
154
+ ```bash
155
+ mkdir -p "$(dirname '{state_file_path}')"
156
+ printf '{{"version":"1","status":"DONE","session_id":"{session_id}",'\
157
+ '"timestamp":"%s","summary":"PR opened",'\
158
+ '"outputs":{{"pr_url":"%s","pr_number":%d}}}}' \\
159
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<PR_URL>" <PR_NUM> > '{state_file_path}'
160
+ ```
161
+
162
+ **BLOCKED** (need input):
163
+ ```bash
164
+ mkdir -p "$(dirname '{state_file_path}')"
165
+ printf '{{"version":"1","status":"BLOCKED_NEEDS_INPUT",'\
166
+ '"session_id":"{session_id}","timestamp":"%s","question":"%s"}}' \\
167
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<QUESTION>" > '{state_file_path}'
168
+ ```
169
+
170
+ **FAILED**:
171
+ ```bash
172
+ mkdir -p "$(dirname '{state_file_path}')"
173
+ printf '{{"version":"1","status":"FAILED","session_id":"{session_id}",'\
174
+ '"timestamp":"%s","error":"%s"}}' \\
175
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<ERROR>" > '{state_file_path}'
176
+ ```"""
177
+
178
+ def _session_to_result(self, result: SessionResult) -> PipelineResult:
179
+ """Convert SessionResult to PipelineResult."""
180
+ if result.state is None:
181
+ return PipelineResult(
182
+ success=False,
183
+ session_id=result.session_id,
184
+ summary="No checkpoint state returned",
185
+ error=result.stderr or "Unknown error",
186
+ )
187
+
188
+ if result.state.status == CheckpointStatus.DONE:
189
+ return PipelineResult(
190
+ success=True,
191
+ session_id=result.session_id,
192
+ summary=result.state.summary or "Completed",
193
+ outputs=result.state.outputs,
194
+ )
195
+
196
+ if result.state.status == CheckpointStatus.BLOCKED_NEEDS_INPUT:
197
+ return PipelineResult(
198
+ success=False,
199
+ session_id=result.session_id,
200
+ summary="Blocked on user input",
201
+ blocked=True,
202
+ question=result.state.question,
203
+ )
204
+
205
+ return PipelineResult(
206
+ success=False,
207
+ session_id=result.session_id,
208
+ summary="Failed",
209
+ error=result.state.error,
210
+ )
211
+
212
+
213
+ def _build_fix_prompt(pr_number: int, verification: VerificationResult) -> str:
214
+ """Build a resume-prompt asking Claude to fix CI failures or merge conflicts."""
215
+ if verification.mergeable == "CONFLICTING":
216
+ return (
217
+ f"PR #{pr_number} has merge conflicts with the base branch "
218
+ f"(mergeStateStatus={verification.merge_state_status}). "
219
+ "Rebase the branch onto the base, resolve the conflicts, push the "
220
+ "updated branch, then signal DONE with the same "
221
+ f'outputs (pr_url, pr_number={pr_number}) once the PR reports '
222
+ "MERGEABLE. Signal FAILED if conflicts cannot be resolved."
223
+ )
224
+
225
+ if verification.merge_state_status == "BEHIND":
226
+ return (
227
+ f"PR #{pr_number} is behind the base branch and base-branch "
228
+ "protection requires it to be up-to-date before merge "
229
+ "(mergeStateStatus=BEHIND). Rebase the branch onto the base and "
230
+ "force-push, then signal DONE with the same "
231
+ f"outputs (pr_url, pr_number={pr_number}) once the PR reports "
232
+ "mergeStateStatus=CLEAN."
233
+ )
234
+
235
+ if verification.failing_checks:
236
+ names = ", ".join(
237
+ f"{c.get('name', '?')}={c.get('state') or c.get('bucket')}"
238
+ for c in verification.failing_checks
239
+ )
240
+ return (
241
+ f"PR #{pr_number} has failing or incomplete CI checks: {names}. "
242
+ "Investigate the failures (fetch logs via `gh run view` as needed), "
243
+ "fix the underlying issues, commit and push, then wait for CI to go "
244
+ "green. Call checkpoint.done() with the same outputs once all checks "
245
+ "pass and the PR is MERGEABLE."
246
+ )
247
+
248
+ return (
249
+ f"PR #{pr_number} is not ready to hand off: {verification.reason}. "
250
+ "Investigate, push any required fixes, and call checkpoint.done() once "
251
+ "CI is green and the PR is MERGEABLE."
252
+ )
253
+
254
+
255
+ async def _verify_and_fix_pr(
256
+ *,
257
+ pipeline: DevPipeline,
258
+ ctx: PipelineContext,
259
+ result: PipelineResult,
260
+ verifier: PRVerifier,
261
+ max_attempts: int,
262
+ ) -> PipelineResult:
263
+ """Loop: verify CI+mergeability, ask Claude to fix, re-verify."""
264
+ pr_number_raw = result.outputs.get("pr_number")
265
+ if pr_number_raw is None:
266
+ return result
267
+ pr_number = int(pr_number_raw)
268
+
269
+ verification = await verifier.verify(ctx.repo, pr_number)
270
+ # If CI is simply slow (timed_out) we hand the PR off rather than asking
271
+ # Claude to "fix" something that isn't broken.
272
+ if verification.timed_out:
273
+ return result
274
+ attempts = 0
275
+ while not verification.ready and attempts < max_attempts:
276
+ fix_prompt = _build_fix_prompt(pr_number, verification)
277
+ fix_result = await pipeline.request_fix(ctx, fix_prompt)
278
+ attempts += 1
279
+
280
+ if not fix_result.success:
281
+ # Preserve the original PR info in outputs so callers can still find it.
282
+ merged_outputs = dict(result.outputs)
283
+ merged_outputs.update(fix_result.outputs)
284
+ return PipelineResult(
285
+ success=False,
286
+ session_id=fix_result.session_id,
287
+ summary=fix_result.summary,
288
+ blocked=fix_result.blocked,
289
+ question=fix_result.question,
290
+ error=fix_result.error,
291
+ outputs=merged_outputs,
292
+ )
293
+
294
+ result = fix_result
295
+ verification = await verifier.verify(ctx.repo, pr_number)
296
+ if verification.timed_out:
297
+ # Same rule after a fix round: slow CI isn't a Claude task.
298
+ return result
299
+
300
+ if verification.ready:
301
+ return result
302
+
303
+ return PipelineResult(
304
+ success=False,
305
+ session_id=result.session_id,
306
+ summary=(
307
+ f"PR #{pr_number} not ready after {max_attempts} fix attempt(s): "
308
+ f"{verification.reason}"
309
+ ),
310
+ error=verification.reason,
311
+ outputs=result.outputs,
312
+ )
313
+
314
+
315
+ async def run_dev_issue(
316
+ repo: str,
317
+ issue_number: int,
318
+ branch_template: str,
319
+ dispatcher: AgentAdapter,
320
+ github: GitHubCLI,
321
+ worktree: WorktreeManager,
322
+ dashboard: DashboardClient | None,
323
+ state_db: StateDB,
324
+ transport: Transport | None,
325
+ contexts_dir: Path,
326
+ max_fix_attempts: int = DEFAULT_MAX_FIX_ATTEMPTS,
327
+ max_blocked_rounds: int = DEFAULT_MAX_BLOCKED_ROUNDS,
328
+ pr_verifier: PRVerifier | None = None,
329
+ ) -> PipelineResult:
330
+ """Run dev pipeline for a single issue."""
331
+ session_id = f"dev-{repo.replace('/', '-')}-{issue_number}-{uuid.uuid4().hex[:8]}"
332
+ branch_name = branch_template.replace("{n}", str(issue_number))
333
+
334
+ if not state_db.acquire_lock(repo, session_id):
335
+ return PipelineResult(
336
+ success=False,
337
+ session_id=session_id,
338
+ summary=f"Could not acquire lock for {repo}",
339
+ error="Repository locked by another session",
340
+ )
341
+
342
+ worktree_path: Path | None = None
343
+ # Pessimistic default: if we never got far enough to check, assume the
344
+ # branch was pre-existing so cleanup never clobbers unrelated state.
345
+ branch_preexisted = True
346
+
347
+ try:
348
+ # Get issue details
349
+ issue = await github.get_issue(repo, issue_number)
350
+
351
+ # Post claim comment so collaborators can see the agent picked it up.
352
+ # Skip if a previous attempt already left the marker — keeps it idempotent
353
+ # across retries / resumed sessions.
354
+ existing_comments = issue.get("comments") or []
355
+ already_claimed = any(
356
+ AGENT_CLAIM_MARKER in (c.get("body") or "")
357
+ for c in existing_comments
358
+ )
359
+ if not already_claimed:
360
+ await github.comment_on_issue(
361
+ repo=repo,
362
+ issue_number=issue_number,
363
+ body=AGENT_CLAIM_COMMENT,
364
+ )
365
+
366
+ # Snapshot branch ownership BEFORE we try to create it. If the ref
367
+ # already exists in the bare repo, it came from another run (possibly
368
+ # a prior DONE session whose PR is still open) and we must not touch
369
+ # it. If it does not exist here, any ref we see later belongs to us —
370
+ # even if `git worktree add -b` fails partway through and leaves the
371
+ # ref behind without a usable worktree.
372
+ await worktree.ensure_bare_repo(repo)
373
+ branch_preexisted = await worktree.branch_exists_locally(repo, branch_name)
374
+ worktree_path = await worktree.create_worktree_with_new_branch(
375
+ repo=repo,
376
+ session_id=session_id,
377
+ new_branch=branch_name,
378
+ )
379
+
380
+ # Symlink context
381
+ context_path = contexts_dir / repo.replace("/", "-") / "CLAUDE.md"
382
+ if context_path.exists():
383
+ worktree.symlink_context(worktree_path, context_path)
384
+
385
+ # Setup state file
386
+ state_file = worktree_path / ".ctrlrelay" / "state.json"
387
+ state_file.parent.mkdir(parents=True, exist_ok=True)
388
+
389
+ ctx = PipelineContext(
390
+ session_id=session_id,
391
+ repo=repo,
392
+ worktree_path=worktree_path,
393
+ context_path=context_path,
394
+ state_file=state_file,
395
+ issue_number=issue_number,
396
+ extra={
397
+ "issue_title": issue.get("title", ""),
398
+ "issue_body": issue.get("body", ""),
399
+ "branch_name": branch_name,
400
+ },
401
+ )
402
+
403
+ # Record session
404
+ state_db.execute(
405
+ """INSERT INTO sessions
406
+ (id, pipeline, repo, worktree_path, status, started_at, issue_number)
407
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
408
+ (
409
+ session_id, "dev", repo, str(worktree_path),
410
+ "running", int(time.time()), issue_number,
411
+ ),
412
+ )
413
+ state_db.commit()
414
+
415
+ # Run pipeline
416
+ pipeline = DevPipeline(
417
+ dispatcher=dispatcher,
418
+ github=github,
419
+ worktree=worktree,
420
+ dashboard=dashboard,
421
+ state_db=state_db,
422
+ transport=transport,
423
+ )
424
+ result = await pipeline.run(ctx)
425
+
426
+ # BLOCKED loop: if Claude needs input, post the question to the
427
+ # transport (Telegram), wait for the operator's reply, and resume
428
+ # the session. Loop until Claude signals DONE/FAILED or we run out
429
+ # of trips. Each round-trip is capped at the transport's own
430
+ # timeout (see Transport.ask signature — default 300s); the total
431
+ # is bounded by max_blocked_rounds.
432
+ rounds = 0
433
+ while (
434
+ result.blocked
435
+ and transport is not None
436
+ and rounds < max_blocked_rounds
437
+ ):
438
+ question = (result.question or "").strip() or (
439
+ f"Session {session_id} is blocked but did not include a "
440
+ "question. Reply with guidance to resume."
441
+ )
442
+ try:
443
+ answer = await transport.ask(
444
+ question,
445
+ session_id=session_id,
446
+ repo=repo,
447
+ issue_number=issue_number,
448
+ )
449
+ except Exception as e:
450
+ # Transport failed (bridge down, timeout, etc.) — give up
451
+ # cleanly and fall through to the normal blocked cleanup.
452
+ result = PipelineResult(
453
+ success=False,
454
+ blocked=False,
455
+ session_id=session_id,
456
+ summary=f"Blocked session abandoned: {e}",
457
+ error=str(e),
458
+ outputs=result.outputs,
459
+ )
460
+ break
461
+ rounds += 1
462
+ result = await pipeline.resume(ctx, answer)
463
+
464
+ # Verify PR is green & conflict-free before handing off. Resume the
465
+ # session with a fix request if either is broken, up to max_fix_attempts.
466
+ if result.success and result.outputs.get("pr_number") is not None:
467
+ verifier = pr_verifier or PRVerifier(github=github)
468
+ result = await _verify_and_fix_pr(
469
+ pipeline=pipeline,
470
+ ctx=ctx,
471
+ result=result,
472
+ verifier=verifier,
473
+ max_attempts=max_fix_attempts,
474
+ )
475
+
476
+ # Update session status
477
+ status = "done" if result.success else ("blocked" if result.blocked else "failed")
478
+ state_db.execute(
479
+ "UPDATE sessions SET status = ?, summary = ?, ended_at = ? WHERE id = ?",
480
+ (status, result.summary, int(time.time()), session_id),
481
+ )
482
+ state_db.commit()
483
+
484
+ # Push event to dashboard
485
+ if dashboard and result.success:
486
+ await dashboard.push_event(EventPayload(
487
+ level="info",
488
+ pipeline="dev",
489
+ repo=repo,
490
+ message=result.summary,
491
+ session_id=session_id,
492
+ details={
493
+ "issue_number": issue_number,
494
+ "pr_number": result.outputs.get("pr_number"),
495
+ },
496
+ ))
497
+
498
+ # Cleanup rules:
499
+ # DONE -> remove worktree, keep branch (the open PR references it)
500
+ # BLOCKED -> keep both (user may resume the session)
501
+ # FAILED -> remove worktree AND delete branch so the next retry can
502
+ # re-create `fix/issue-<n>` cleanly — BUT only if the
503
+ # branch did not pre-exist (we own it) and it was never
504
+ # pushed (no recoverable work on origin).
505
+ if result.success:
506
+ worktree.remove_context_symlink(worktree_path)
507
+ await worktree.remove_worktree(repo, session_id)
508
+ elif not result.blocked:
509
+ worktree.remove_context_symlink(worktree_path)
510
+ await worktree.remove_worktree(repo, session_id)
511
+ if not branch_preexisted and not await worktree.branch_exists_on_remote(
512
+ repo, branch_name
513
+ ):
514
+ await worktree.delete_branch(repo, branch_name)
515
+
516
+ return result
517
+
518
+ except Exception as e:
519
+ state_db.execute(
520
+ "UPDATE sessions SET status = ?, summary = ?, ended_at = ? WHERE id = ?",
521
+ ("failed", f"Error: {e}", int(time.time()), session_id),
522
+ )
523
+ state_db.commit()
524
+
525
+ # Best-effort cleanup so a retry isn't blocked by leftover state. Only
526
+ # touch the branch if it didn't pre-exist (we own it) AND origin has
527
+ # no copy (no recoverable work to orphan). Covers partial failures of
528
+ # `git worktree add -b` that create the ref before the directory setup
529
+ # crashes.
530
+ if worktree_path is not None:
531
+ try:
532
+ worktree.remove_context_symlink(worktree_path)
533
+ except Exception:
534
+ pass
535
+ # Always attempt remove_worktree + prune — handles the case where
536
+ # `git worktree add -b` registered worktree metadata before the dir
537
+ # step failed, leaving worktree_path unassigned but metadata in the
538
+ # bare repo that would prevent the branch from being deleted.
539
+ try:
540
+ await worktree.remove_worktree(repo, session_id)
541
+ except Exception:
542
+ pass
543
+ if not branch_preexisted:
544
+ try:
545
+ has_remote = await worktree.branch_exists_on_remote(repo, branch_name)
546
+ except Exception:
547
+ has_remote = True
548
+ if not has_remote:
549
+ try:
550
+ await worktree.delete_branch(repo, branch_name)
551
+ except Exception:
552
+ pass
553
+
554
+ return PipelineResult(
555
+ success=False,
556
+ session_id=session_id,
557
+ summary=f"Error processing issue #{issue_number}",
558
+ error=str(e),
559
+ )
560
+
561
+ finally:
562
+ state_db.release_lock(repo, session_id)