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,713 @@
|
|
|
1
|
+
"""Task pipeline for non-PR GitHub issues.
|
|
2
|
+
|
|
3
|
+
A ``task``-labeled issue routes here instead of the dev pipeline. The
|
|
4
|
+
agent is told to do the work described (run a build, investigate a
|
|
5
|
+
failure, check a config, etc.) and report its findings as an issue
|
|
6
|
+
comment. No branch, no PR. Useful when the outcome is information
|
|
7
|
+
rather than code changes.
|
|
8
|
+
|
|
9
|
+
Shared pieces with the dev pipeline:
|
|
10
|
+
- Per-repo state_db lock so secops / dev / task runs on the same repo
|
|
11
|
+
serialize.
|
|
12
|
+
- Worktree on the repo's default branch (read-only work; the agent
|
|
13
|
+
can still invoke tools, compile, run tests).
|
|
14
|
+
- BLOCKED → pending_resumes persistence + sweeper resume, identical
|
|
15
|
+
to dev/secops, so a Telegram reply after an exit still lands.
|
|
16
|
+
|
|
17
|
+
Intentionally NOT shared with dev:
|
|
18
|
+
- No branch creation or PR handoff.
|
|
19
|
+
- No claim comment (the agent writes its own report-comment on exit,
|
|
20
|
+
which serves as the equivalent "agent touched this" signal).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import time
|
|
27
|
+
import uuid
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from ctrlrelay.core.checkpoint import CheckpointStatus
|
|
32
|
+
from ctrlrelay.core.dispatcher import AgentAdapter, SessionResult
|
|
33
|
+
from ctrlrelay.core.github import GitHubCLI
|
|
34
|
+
from ctrlrelay.core.obs import get_logger, hash_text, log_event
|
|
35
|
+
from ctrlrelay.core.state import StateDB
|
|
36
|
+
from ctrlrelay.core.worktree import WorktreeManager
|
|
37
|
+
from ctrlrelay.dashboard.client import DashboardClient, EventPayload
|
|
38
|
+
from ctrlrelay.pipelines.base import PipelineContext, PipelineResult
|
|
39
|
+
from ctrlrelay.transports.base import Transport
|
|
40
|
+
|
|
41
|
+
DEFAULT_MAX_BLOCKED_ROUNDS = 5
|
|
42
|
+
|
|
43
|
+
_logger = get_logger("pipeline.task")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _question_for_persist(session_id: str, result: PipelineResult) -> str:
|
|
47
|
+
"""Fallback question text when the agent signals BLOCKED with no
|
|
48
|
+
question body — ensures persisted rows are always non-empty so an
|
|
49
|
+
orphan Telegram reply has something to match on."""
|
|
50
|
+
q = (result.question or "").strip()
|
|
51
|
+
if q:
|
|
52
|
+
return q
|
|
53
|
+
return (
|
|
54
|
+
f"Session {session_id} is blocked but did not include a "
|
|
55
|
+
"question. Reply with guidance to resume."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class TaskPipeline:
|
|
61
|
+
"""Task pipeline: agent does the work, posts a report-comment,
|
|
62
|
+
signals DONE. No branch, no PR."""
|
|
63
|
+
|
|
64
|
+
dispatcher: AgentAdapter
|
|
65
|
+
github: GitHubCLI
|
|
66
|
+
worktree: WorktreeManager
|
|
67
|
+
dashboard: DashboardClient | None
|
|
68
|
+
state_db: StateDB
|
|
69
|
+
transport: Transport | None
|
|
70
|
+
|
|
71
|
+
name: str = "task"
|
|
72
|
+
|
|
73
|
+
async def run(self, ctx: PipelineContext) -> PipelineResult:
|
|
74
|
+
prompt = self._build_prompt(
|
|
75
|
+
ctx.repo, ctx.issue_number, ctx.extra,
|
|
76
|
+
session_id=ctx.session_id,
|
|
77
|
+
state_file=ctx.state_file,
|
|
78
|
+
)
|
|
79
|
+
result = await self._spawn(ctx, prompt, resume=False)
|
|
80
|
+
return self._session_to_result(result)
|
|
81
|
+
|
|
82
|
+
async def resume(self, ctx: PipelineContext, answer: str) -> PipelineResult:
|
|
83
|
+
prompt = f"User answered: {answer}\n\nContinue from where you left off."
|
|
84
|
+
resume_uuid = self.state_db.get_agent_session_id(ctx.session_id)
|
|
85
|
+
log_event(
|
|
86
|
+
_logger,
|
|
87
|
+
"task.session.resumed",
|
|
88
|
+
session_id=ctx.session_id,
|
|
89
|
+
repo=ctx.repo,
|
|
90
|
+
issue_number=ctx.issue_number,
|
|
91
|
+
pipeline=self.name,
|
|
92
|
+
resume_session_id=resume_uuid,
|
|
93
|
+
answer_length=len(answer),
|
|
94
|
+
answer_hash=hash_text(answer),
|
|
95
|
+
)
|
|
96
|
+
result = await self._spawn(ctx, prompt, resume=True)
|
|
97
|
+
return self._session_to_result(result)
|
|
98
|
+
|
|
99
|
+
async def _spawn(
|
|
100
|
+
self,
|
|
101
|
+
ctx: PipelineContext,
|
|
102
|
+
prompt: str,
|
|
103
|
+
*,
|
|
104
|
+
resume: bool,
|
|
105
|
+
) -> SessionResult:
|
|
106
|
+
resume_uuid: str | None = None
|
|
107
|
+
if resume:
|
|
108
|
+
resume_uuid = self.state_db.get_agent_session_id(ctx.session_id)
|
|
109
|
+
|
|
110
|
+
result = await self.dispatcher.spawn_session(
|
|
111
|
+
session_id=ctx.session_id,
|
|
112
|
+
prompt=prompt,
|
|
113
|
+
working_dir=ctx.worktree_path,
|
|
114
|
+
state_file=ctx.state_file,
|
|
115
|
+
resume_session_id=resume_uuid,
|
|
116
|
+
repo=ctx.repo,
|
|
117
|
+
issue_number=ctx.issue_number,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if result.agent_session_id:
|
|
121
|
+
try:
|
|
122
|
+
self.state_db.set_agent_session_id(
|
|
123
|
+
ctx.session_id, result.agent_session_id
|
|
124
|
+
)
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
def _build_prompt(
|
|
130
|
+
self,
|
|
131
|
+
repo: str,
|
|
132
|
+
issue_number: int | None,
|
|
133
|
+
extra: dict,
|
|
134
|
+
*,
|
|
135
|
+
session_id: str = "",
|
|
136
|
+
state_file: Path | None = None,
|
|
137
|
+
) -> str:
|
|
138
|
+
state_file_path = str(state_file) if state_file else "/tmp/state.json"
|
|
139
|
+
title = extra.get("issue_title", "") if extra else ""
|
|
140
|
+
body = extra.get("issue_body", "") if extra else ""
|
|
141
|
+
|
|
142
|
+
return f"""You are the ctrlrelay task pipeline. Handle a
|
|
143
|
+
non-PR task for issue #{issue_number} in {repo}.
|
|
144
|
+
|
|
145
|
+
**Issue title:** {title}
|
|
146
|
+
|
|
147
|
+
**Issue body:**
|
|
148
|
+
{body}
|
|
149
|
+
|
|
150
|
+
## What "task" means here
|
|
151
|
+
|
|
152
|
+
This issue is tagged as a task, which means the operator wants you
|
|
153
|
+
to DO the work described and REPORT your findings. You are NOT
|
|
154
|
+
expected to write code, create a branch, or open a PR. Typical task
|
|
155
|
+
shapes: run a build and report errors, investigate a failure,
|
|
156
|
+
check a config, summarize state of something.
|
|
157
|
+
|
|
158
|
+
## Your workflow
|
|
159
|
+
|
|
160
|
+
1. Read the issue above. You have a clean worktree of {repo}'s
|
|
161
|
+
default branch at the current directory — files are real; tools
|
|
162
|
+
like `gh`, `git`, `npm`, `uv`, `cargo`, etc. are on PATH.
|
|
163
|
+
2. Do the work. Run commands, read files, query APIs. You can write
|
|
164
|
+
files in the worktree if it helps your investigation, but those
|
|
165
|
+
changes will be discarded at the end — do not rely on them
|
|
166
|
+
persisting.
|
|
167
|
+
3. When you have your findings, post them as a comment on the issue
|
|
168
|
+
using:
|
|
169
|
+
```bash
|
|
170
|
+
gh issue comment {issue_number} --repo {repo} --body "<your findings>"
|
|
171
|
+
```
|
|
172
|
+
The comment is your primary output. Keep it concise but concrete:
|
|
173
|
+
what you found, what commands you ran, any snippets the operator
|
|
174
|
+
should see. No unnecessary throat-clearing.
|
|
175
|
+
4. Signal DONE with a short summary (the Telegram notification the
|
|
176
|
+
operator sees is based on this summary, so make it useful).
|
|
177
|
+
|
|
178
|
+
If you hit genuine ambiguity that needs the operator's input,
|
|
179
|
+
signal BLOCKED with a specific question — same pattern as the dev
|
|
180
|
+
pipeline. Don't BLOCK on things you can figure out yourself.
|
|
181
|
+
|
|
182
|
+
## Signaling completion
|
|
183
|
+
|
|
184
|
+
**CRITICAL**: Before exiting, write a checkpoint file.
|
|
185
|
+
|
|
186
|
+
STATE_FILE: {state_file_path}
|
|
187
|
+
SESSION_ID: {session_id}
|
|
188
|
+
|
|
189
|
+
**DONE** (task complete, comment posted):
|
|
190
|
+
```bash
|
|
191
|
+
mkdir -p "$(dirname '{state_file_path}')"
|
|
192
|
+
printf '{{"version":"1","status":"DONE","session_id":"{session_id}",'\\
|
|
193
|
+
'"timestamp":"%s","summary":"%s"}}' \\
|
|
194
|
+
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<ONE-LINE SUMMARY>" > '{state_file_path}'
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**BLOCKED** (need operator input):
|
|
198
|
+
```bash
|
|
199
|
+
printf '{{"version":"1","status":"BLOCKED_NEEDS_INPUT",'\\
|
|
200
|
+
'"session_id":"{session_id}","timestamp":"%s","question":"%s"}}' \\
|
|
201
|
+
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<QUESTION>" > '{state_file_path}'
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**FAILED** (genuine failure, no way forward):
|
|
205
|
+
```bash
|
|
206
|
+
printf '{{"version":"1","status":"FAILED",'\\
|
|
207
|
+
'"session_id":"{session_id}","timestamp":"%s","error":"%s"}}' \\
|
|
208
|
+
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<ERROR>" > '{state_file_path}'
|
|
209
|
+
```
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
def _session_to_result(self, result: SessionResult) -> PipelineResult:
|
|
213
|
+
state = result.state
|
|
214
|
+
if state is None:
|
|
215
|
+
return PipelineResult(
|
|
216
|
+
success=False,
|
|
217
|
+
session_id=result.session_id,
|
|
218
|
+
summary="Task session ended without a checkpoint",
|
|
219
|
+
error="no checkpoint written",
|
|
220
|
+
)
|
|
221
|
+
if state.status == CheckpointStatus.DONE:
|
|
222
|
+
return PipelineResult(
|
|
223
|
+
success=True,
|
|
224
|
+
session_id=result.session_id,
|
|
225
|
+
summary=state.summary or "Task complete",
|
|
226
|
+
)
|
|
227
|
+
if state.status == CheckpointStatus.BLOCKED_NEEDS_INPUT:
|
|
228
|
+
return PipelineResult(
|
|
229
|
+
success=False,
|
|
230
|
+
blocked=True,
|
|
231
|
+
session_id=result.session_id,
|
|
232
|
+
summary="Blocked on user input",
|
|
233
|
+
question=state.question,
|
|
234
|
+
)
|
|
235
|
+
if state.status == CheckpointStatus.FAILED:
|
|
236
|
+
return PipelineResult(
|
|
237
|
+
success=False,
|
|
238
|
+
session_id=result.session_id,
|
|
239
|
+
summary="Task failed",
|
|
240
|
+
error=state.error or "unknown failure",
|
|
241
|
+
)
|
|
242
|
+
return PipelineResult(
|
|
243
|
+
success=False,
|
|
244
|
+
session_id=result.session_id,
|
|
245
|
+
summary=f"Unexpected checkpoint status: {state.status}",
|
|
246
|
+
error=f"unexpected status {state.status}",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def run_task_issue(
|
|
251
|
+
repo: str,
|
|
252
|
+
issue_number: int,
|
|
253
|
+
dispatcher: AgentAdapter,
|
|
254
|
+
github: GitHubCLI,
|
|
255
|
+
worktree: WorktreeManager,
|
|
256
|
+
dashboard: DashboardClient | None,
|
|
257
|
+
state_db: StateDB,
|
|
258
|
+
transport: Transport | None,
|
|
259
|
+
contexts_dir: Path,
|
|
260
|
+
max_blocked_rounds: int = DEFAULT_MAX_BLOCKED_ROUNDS,
|
|
261
|
+
) -> PipelineResult:
|
|
262
|
+
"""Run the task pipeline for a single issue. Mirrors run_dev_issue's
|
|
263
|
+
lock/worktree/BLOCKED-loop pattern minus the branch-and-PR work."""
|
|
264
|
+
session_id = (
|
|
265
|
+
f"task-{repo.replace('/', '-')}-{issue_number}-"
|
|
266
|
+
f"{uuid.uuid4().hex[:8]}"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if not state_db.acquire_lock(repo, session_id):
|
|
270
|
+
return PipelineResult(
|
|
271
|
+
success=False,
|
|
272
|
+
session_id=session_id,
|
|
273
|
+
summary=f"Could not acquire lock for {repo}",
|
|
274
|
+
error="Repository locked by another session",
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
worktree_path: Path | None = None
|
|
278
|
+
session_row_inserted = False
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
issue = await github.get_issue(repo, issue_number)
|
|
282
|
+
|
|
283
|
+
await worktree.ensure_bare_repo(repo)
|
|
284
|
+
# Default branch, session-scoped name so cleanup is keyed to
|
|
285
|
+
# this session only. Read-only intent (agent won't commit),
|
|
286
|
+
# but the worktree is a real checkout so builds / test
|
|
287
|
+
# runners work.
|
|
288
|
+
worktree_path = await worktree.create_worktree(repo, session_id)
|
|
289
|
+
|
|
290
|
+
context_path = contexts_dir / repo.replace("/", "-") / "CLAUDE.md"
|
|
291
|
+
if context_path.exists():
|
|
292
|
+
worktree.symlink_context(worktree_path, context_path)
|
|
293
|
+
|
|
294
|
+
state_file = worktree_path / ".ctrlrelay" / "state.json"
|
|
295
|
+
state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
296
|
+
|
|
297
|
+
ctx = PipelineContext(
|
|
298
|
+
session_id=session_id,
|
|
299
|
+
repo=repo,
|
|
300
|
+
worktree_path=worktree_path,
|
|
301
|
+
context_path=context_path,
|
|
302
|
+
state_file=state_file,
|
|
303
|
+
issue_number=issue_number,
|
|
304
|
+
extra={
|
|
305
|
+
"issue_title": issue.get("title", ""),
|
|
306
|
+
"issue_body": issue.get("body", ""),
|
|
307
|
+
},
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
state_db.execute(
|
|
311
|
+
"""INSERT INTO sessions
|
|
312
|
+
(id, pipeline, repo, worktree_path, status, started_at,
|
|
313
|
+
issue_number)
|
|
314
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
|
315
|
+
(
|
|
316
|
+
session_id, "task", repo, str(worktree_path),
|
|
317
|
+
"running", int(time.time()), issue_number,
|
|
318
|
+
),
|
|
319
|
+
)
|
|
320
|
+
state_db.commit()
|
|
321
|
+
session_row_inserted = True
|
|
322
|
+
|
|
323
|
+
pipeline = TaskPipeline(
|
|
324
|
+
dispatcher=dispatcher,
|
|
325
|
+
github=github,
|
|
326
|
+
worktree=worktree,
|
|
327
|
+
dashboard=dashboard,
|
|
328
|
+
state_db=state_db,
|
|
329
|
+
transport=transport,
|
|
330
|
+
)
|
|
331
|
+
result = await pipeline.run(ctx)
|
|
332
|
+
|
|
333
|
+
rounds = 0
|
|
334
|
+
while (
|
|
335
|
+
result.blocked
|
|
336
|
+
and transport is not None
|
|
337
|
+
and rounds < max_blocked_rounds
|
|
338
|
+
):
|
|
339
|
+
question = _question_for_persist(session_id, result)
|
|
340
|
+
try:
|
|
341
|
+
answer = await transport.ask(
|
|
342
|
+
question,
|
|
343
|
+
session_id=session_id,
|
|
344
|
+
repo=repo,
|
|
345
|
+
issue_number=issue_number,
|
|
346
|
+
)
|
|
347
|
+
except Exception as e:
|
|
348
|
+
# Preserve blocked=True on transport failure so outer
|
|
349
|
+
# persistence fires — matches the dev pipeline fix.
|
|
350
|
+
result = PipelineResult(
|
|
351
|
+
success=False,
|
|
352
|
+
blocked=True,
|
|
353
|
+
session_id=session_id,
|
|
354
|
+
summary=f"Blocked session deferred (transport): {e}",
|
|
355
|
+
error=str(e),
|
|
356
|
+
question=question,
|
|
357
|
+
outputs=result.outputs,
|
|
358
|
+
)
|
|
359
|
+
break
|
|
360
|
+
rounds += 1
|
|
361
|
+
result = await pipeline.resume(ctx, answer)
|
|
362
|
+
|
|
363
|
+
status = "done" if result.success else (
|
|
364
|
+
"blocked" if result.blocked else "failed"
|
|
365
|
+
)
|
|
366
|
+
state_db.execute(
|
|
367
|
+
"UPDATE sessions SET status = ?, summary = ?, ended_at = ? "
|
|
368
|
+
"WHERE id = ?",
|
|
369
|
+
(status, result.summary, int(time.time()), session_id),
|
|
370
|
+
)
|
|
371
|
+
state_db.commit()
|
|
372
|
+
|
|
373
|
+
if result.blocked:
|
|
374
|
+
try:
|
|
375
|
+
state_db.add_pending_resume(
|
|
376
|
+
session_id=session_id,
|
|
377
|
+
pipeline="task",
|
|
378
|
+
repo=repo,
|
|
379
|
+
question=_question_for_persist(session_id, result),
|
|
380
|
+
)
|
|
381
|
+
except Exception as e:
|
|
382
|
+
log_event(
|
|
383
|
+
_logger,
|
|
384
|
+
"task.pending_resume.insert_failed",
|
|
385
|
+
session_id=session_id,
|
|
386
|
+
repo=repo,
|
|
387
|
+
issue_number=issue_number,
|
|
388
|
+
error_type=type(e).__name__,
|
|
389
|
+
error=str(e)[:200],
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
if dashboard and result.success:
|
|
393
|
+
await dashboard.push_event(EventPayload(
|
|
394
|
+
level="info",
|
|
395
|
+
pipeline="task",
|
|
396
|
+
repo=repo,
|
|
397
|
+
message=result.summary,
|
|
398
|
+
session_id=session_id,
|
|
399
|
+
details={"issue_number": issue_number},
|
|
400
|
+
))
|
|
401
|
+
|
|
402
|
+
return result
|
|
403
|
+
|
|
404
|
+
except Exception as e:
|
|
405
|
+
if session_row_inserted:
|
|
406
|
+
state_db.execute(
|
|
407
|
+
"UPDATE sessions SET status = ?, summary = ?, "
|
|
408
|
+
"ended_at = ? WHERE id = ?",
|
|
409
|
+
("failed", f"Error: {e}", int(time.time()), session_id),
|
|
410
|
+
)
|
|
411
|
+
state_db.commit()
|
|
412
|
+
return PipelineResult(
|
|
413
|
+
success=False,
|
|
414
|
+
session_id=session_id,
|
|
415
|
+
summary=f"Error processing task issue #{issue_number}",
|
|
416
|
+
error=str(e),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
finally:
|
|
420
|
+
# Task workflow: worktree is ephemeral (no PR depends on it).
|
|
421
|
+
# Tear down on every outcome — even BLOCKED, because a task
|
|
422
|
+
# resume will re-create the worktree from scratch anyway
|
|
423
|
+
# (no in-progress branch state to preserve, unlike dev).
|
|
424
|
+
#
|
|
425
|
+
# Timeout + CancelledError handling mirrors secops: the repo
|
|
426
|
+
# lock is shared across task/dev/secops, so a cancel during
|
|
427
|
+
# `remove_worktree` that skipped release_lock would wedge ALL
|
|
428
|
+
# three pipelines for that repo until the row was manually
|
|
429
|
+
# cleared — bad. Release the lock early on cancel and
|
|
430
|
+
# re-raise; on timeout, log and fall through to the normal
|
|
431
|
+
# lock release below.
|
|
432
|
+
if worktree_path is not None:
|
|
433
|
+
try:
|
|
434
|
+
worktree.remove_context_symlink(worktree_path)
|
|
435
|
+
except Exception as cleanup_exc:
|
|
436
|
+
log_event(
|
|
437
|
+
_logger,
|
|
438
|
+
"task.cleanup.symlink_failed",
|
|
439
|
+
session_id=session_id,
|
|
440
|
+
repo=repo,
|
|
441
|
+
error_type=type(cleanup_exc).__name__,
|
|
442
|
+
error=str(cleanup_exc)[:200],
|
|
443
|
+
)
|
|
444
|
+
try:
|
|
445
|
+
# Timeout matches secops (130s) — a full worktree prune
|
|
446
|
+
# can take up to ~120s on a slow volume.
|
|
447
|
+
await asyncio.wait_for(
|
|
448
|
+
worktree.remove_worktree(repo, session_id),
|
|
449
|
+
timeout=130.0,
|
|
450
|
+
)
|
|
451
|
+
except asyncio.TimeoutError:
|
|
452
|
+
log_event(
|
|
453
|
+
_logger,
|
|
454
|
+
"task.cleanup.worktree_timeout",
|
|
455
|
+
session_id=session_id,
|
|
456
|
+
repo=repo,
|
|
457
|
+
worktree_path=str(worktree_path),
|
|
458
|
+
)
|
|
459
|
+
except asyncio.CancelledError:
|
|
460
|
+
log_event(
|
|
461
|
+
_logger,
|
|
462
|
+
"task.cleanup.worktree_cancelled_mid_shutdown",
|
|
463
|
+
session_id=session_id,
|
|
464
|
+
repo=repo,
|
|
465
|
+
worktree_path=str(worktree_path),
|
|
466
|
+
)
|
|
467
|
+
try:
|
|
468
|
+
state_db.release_lock(repo, session_id)
|
|
469
|
+
except Exception:
|
|
470
|
+
pass
|
|
471
|
+
raise
|
|
472
|
+
except Exception as cleanup_exc:
|
|
473
|
+
log_event(
|
|
474
|
+
_logger,
|
|
475
|
+
"task.cleanup.worktree_failed",
|
|
476
|
+
session_id=session_id,
|
|
477
|
+
repo=repo,
|
|
478
|
+
worktree_path=str(worktree_path),
|
|
479
|
+
error_type=type(cleanup_exc).__name__,
|
|
480
|
+
error=str(cleanup_exc)[:200],
|
|
481
|
+
)
|
|
482
|
+
try:
|
|
483
|
+
state_db.release_lock(repo, session_id)
|
|
484
|
+
except Exception as lock_exc:
|
|
485
|
+
log_event(
|
|
486
|
+
_logger,
|
|
487
|
+
"task.cleanup.lock_release_failed",
|
|
488
|
+
session_id=session_id,
|
|
489
|
+
repo=repo,
|
|
490
|
+
error_type=type(lock_exc).__name__,
|
|
491
|
+
error=str(lock_exc)[:200],
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
async def resume_task_from_pending(
|
|
496
|
+
session_id: str,
|
|
497
|
+
repo: str,
|
|
498
|
+
answer: str,
|
|
499
|
+
dispatcher: AgentAdapter,
|
|
500
|
+
github: GitHubCLI,
|
|
501
|
+
worktree: WorktreeManager,
|
|
502
|
+
dashboard: DashboardClient | None,
|
|
503
|
+
state_db: StateDB,
|
|
504
|
+
transport: Transport | None,
|
|
505
|
+
contexts_dir: Path,
|
|
506
|
+
max_blocked_rounds: int = DEFAULT_MAX_BLOCKED_ROUNDS,
|
|
507
|
+
) -> PipelineResult:
|
|
508
|
+
"""Resume a BLOCKED task session using an answer that arrived
|
|
509
|
+
via Telegram after ``run_task_issue`` exited.
|
|
510
|
+
|
|
511
|
+
Rebuilds the worktree from scratch (task worktrees are torn
|
|
512
|
+
down on every outcome, including BLOCKED — see the finally block
|
|
513
|
+
in run_task_issue). Reuses Claude's session UUID if captured so
|
|
514
|
+
the resumed prompt has access to the prior conversation."""
|
|
515
|
+
session_row = state_db.get_session_row(session_id)
|
|
516
|
+
if session_row is None:
|
|
517
|
+
return PipelineResult(
|
|
518
|
+
success=False,
|
|
519
|
+
session_id=session_id,
|
|
520
|
+
summary=(
|
|
521
|
+
f"Cannot resume task session {session_id}: row missing "
|
|
522
|
+
"from sessions table"
|
|
523
|
+
),
|
|
524
|
+
error="session_row_missing",
|
|
525
|
+
)
|
|
526
|
+
issue_number = session_row.get("issue_number")
|
|
527
|
+
if issue_number is None:
|
|
528
|
+
return PipelineResult(
|
|
529
|
+
success=False,
|
|
530
|
+
session_id=session_id,
|
|
531
|
+
summary=(
|
|
532
|
+
f"Cannot resume task session {session_id}: "
|
|
533
|
+
"issue_number missing on sessions row"
|
|
534
|
+
),
|
|
535
|
+
error="session_issue_number_missing",
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
if not state_db.acquire_lock(repo, session_id):
|
|
539
|
+
return PipelineResult(
|
|
540
|
+
success=False,
|
|
541
|
+
session_id=session_id,
|
|
542
|
+
summary=f"Could not acquire lock for {repo} to resume task",
|
|
543
|
+
error="Repository locked by another session",
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
worktree_path: Path | None = None
|
|
547
|
+
|
|
548
|
+
try:
|
|
549
|
+
await worktree.ensure_bare_repo(repo)
|
|
550
|
+
worktree_path = await worktree.create_worktree(repo, session_id)
|
|
551
|
+
|
|
552
|
+
context_path = contexts_dir / repo.replace("/", "-") / "CLAUDE.md"
|
|
553
|
+
if context_path.exists():
|
|
554
|
+
worktree.symlink_context(worktree_path, context_path)
|
|
555
|
+
|
|
556
|
+
state_file = worktree_path / ".ctrlrelay" / "state.json"
|
|
557
|
+
state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
558
|
+
|
|
559
|
+
issue = await github.get_issue(repo, int(issue_number))
|
|
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
|
+
issue_number=int(issue_number),
|
|
568
|
+
extra={
|
|
569
|
+
"issue_title": issue.get("title", ""),
|
|
570
|
+
"issue_body": issue.get("body", ""),
|
|
571
|
+
},
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
state_db.execute(
|
|
575
|
+
"UPDATE sessions SET status = ?, ended_at = NULL WHERE id = ?",
|
|
576
|
+
("running", session_id),
|
|
577
|
+
)
|
|
578
|
+
state_db.commit()
|
|
579
|
+
|
|
580
|
+
pipeline = TaskPipeline(
|
|
581
|
+
dispatcher=dispatcher,
|
|
582
|
+
github=github,
|
|
583
|
+
worktree=worktree,
|
|
584
|
+
dashboard=dashboard,
|
|
585
|
+
state_db=state_db,
|
|
586
|
+
transport=transport,
|
|
587
|
+
)
|
|
588
|
+
result = await pipeline.resume(ctx, answer)
|
|
589
|
+
|
|
590
|
+
rounds = 0
|
|
591
|
+
while (
|
|
592
|
+
result.blocked
|
|
593
|
+
and transport is not None
|
|
594
|
+
and rounds < max_blocked_rounds
|
|
595
|
+
):
|
|
596
|
+
question = _question_for_persist(session_id, result)
|
|
597
|
+
try:
|
|
598
|
+
next_answer = await transport.ask(
|
|
599
|
+
question,
|
|
600
|
+
session_id=session_id,
|
|
601
|
+
repo=repo,
|
|
602
|
+
issue_number=int(issue_number),
|
|
603
|
+
)
|
|
604
|
+
except Exception as e:
|
|
605
|
+
result = PipelineResult(
|
|
606
|
+
success=False,
|
|
607
|
+
blocked=True,
|
|
608
|
+
session_id=session_id,
|
|
609
|
+
summary=(
|
|
610
|
+
f"Blocked session deferred during resume "
|
|
611
|
+
f"(transport): {e}"
|
|
612
|
+
),
|
|
613
|
+
error=str(e),
|
|
614
|
+
question=question,
|
|
615
|
+
outputs=result.outputs,
|
|
616
|
+
)
|
|
617
|
+
break
|
|
618
|
+
rounds += 1
|
|
619
|
+
result = await pipeline.resume(ctx, next_answer)
|
|
620
|
+
|
|
621
|
+
status = "done" if result.success else (
|
|
622
|
+
"blocked" if result.blocked else "failed"
|
|
623
|
+
)
|
|
624
|
+
state_db.execute(
|
|
625
|
+
"UPDATE sessions SET status = ?, summary = ?, ended_at = ? "
|
|
626
|
+
"WHERE id = ?",
|
|
627
|
+
(status, result.summary, int(time.time()), session_id),
|
|
628
|
+
)
|
|
629
|
+
state_db.commit()
|
|
630
|
+
|
|
631
|
+
if result.blocked:
|
|
632
|
+
try:
|
|
633
|
+
state_db.add_pending_resume(
|
|
634
|
+
session_id=session_id,
|
|
635
|
+
pipeline="task",
|
|
636
|
+
repo=repo,
|
|
637
|
+
question=_question_for_persist(session_id, result),
|
|
638
|
+
)
|
|
639
|
+
except Exception as e:
|
|
640
|
+
log_event(
|
|
641
|
+
_logger,
|
|
642
|
+
"task.pending_resume.reblock_insert_failed",
|
|
643
|
+
session_id=session_id,
|
|
644
|
+
repo=repo,
|
|
645
|
+
issue_number=issue_number,
|
|
646
|
+
error_type=type(e).__name__,
|
|
647
|
+
error=str(e)[:200],
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
return result
|
|
651
|
+
|
|
652
|
+
except Exception as e:
|
|
653
|
+
state_db.execute(
|
|
654
|
+
"UPDATE sessions SET status = ?, summary = ?, ended_at = ? "
|
|
655
|
+
"WHERE id = ?",
|
|
656
|
+
("failed", f"Resume error: {e}", int(time.time()), session_id),
|
|
657
|
+
)
|
|
658
|
+
state_db.commit()
|
|
659
|
+
return PipelineResult(
|
|
660
|
+
success=False,
|
|
661
|
+
session_id=session_id,
|
|
662
|
+
summary=f"Error resuming task session {session_id} on {repo}",
|
|
663
|
+
error=str(e),
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
finally:
|
|
667
|
+
# Same cancel/timeout handling as run_task_issue's finally —
|
|
668
|
+
# shared repo lock means a cancel here would wedge
|
|
669
|
+
# task/dev/secops on this repo if release_lock was skipped.
|
|
670
|
+
if worktree_path is not None:
|
|
671
|
+
try:
|
|
672
|
+
worktree.remove_context_symlink(worktree_path)
|
|
673
|
+
except Exception:
|
|
674
|
+
pass
|
|
675
|
+
try:
|
|
676
|
+
await asyncio.wait_for(
|
|
677
|
+
worktree.remove_worktree(repo, session_id),
|
|
678
|
+
timeout=130.0,
|
|
679
|
+
)
|
|
680
|
+
except asyncio.TimeoutError:
|
|
681
|
+
log_event(
|
|
682
|
+
_logger,
|
|
683
|
+
"task.resume.cleanup.worktree_timeout",
|
|
684
|
+
session_id=session_id,
|
|
685
|
+
repo=repo,
|
|
686
|
+
worktree_path=str(worktree_path),
|
|
687
|
+
)
|
|
688
|
+
except asyncio.CancelledError:
|
|
689
|
+
log_event(
|
|
690
|
+
_logger,
|
|
691
|
+
"task.resume.cleanup.worktree_cancelled_mid_shutdown",
|
|
692
|
+
session_id=session_id,
|
|
693
|
+
repo=repo,
|
|
694
|
+
worktree_path=str(worktree_path),
|
|
695
|
+
)
|
|
696
|
+
try:
|
|
697
|
+
state_db.release_lock(repo, session_id)
|
|
698
|
+
except Exception:
|
|
699
|
+
pass
|
|
700
|
+
raise
|
|
701
|
+
except Exception:
|
|
702
|
+
pass
|
|
703
|
+
try:
|
|
704
|
+
state_db.release_lock(repo, session_id)
|
|
705
|
+
except Exception as lock_exc:
|
|
706
|
+
log_event(
|
|
707
|
+
_logger,
|
|
708
|
+
"task.resume.cleanup.lock_release_failed",
|
|
709
|
+
session_id=session_id,
|
|
710
|
+
repo=repo,
|
|
711
|
+
error_type=type(lock_exc).__name__,
|
|
712
|
+
error=str(lock_exc)[:200],
|
|
713
|
+
)
|