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.
- ctrlrelay/__init__.py +8 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +69 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +285 -0
- ctrlrelay/bridge/telegram_handler.py +117 -0
- ctrlrelay/cli.py +1449 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +291 -0
- ctrlrelay/core/dispatcher.py +202 -0
- ctrlrelay/core/github.py +272 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +319 -0
- ctrlrelay/core/pr_verifier.py +177 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +167 -0
- ctrlrelay/core/worktree.py +673 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +562 -0
- ctrlrelay/pipelines/post_merge.py +279 -0
- ctrlrelay/pipelines/secops.py +379 -0
- ctrlrelay/transports/__init__.py +33 -0
- ctrlrelay/transports/base.py +47 -0
- ctrlrelay/transports/file_mock.py +94 -0
- ctrlrelay/transports/socket_client.py +180 -0
- ctrlrelay-0.1.5.dist-info/METADATA +251 -0
- ctrlrelay-0.1.5.dist-info/RECORD +36 -0
- ctrlrelay-0.1.5.dist-info/WHEEL +4 -0
- ctrlrelay-0.1.5.dist-info/entry_points.txt +2 -0
- ctrlrelay-0.1.5.dist-info/licenses/LICENSE +201 -0
|
@@ -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)
|