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.
Files changed (53) hide show
  1. ctrlrelay/__init__.py +33 -0
  2. ctrlrelay/bridge/__init__.py +21 -0
  3. ctrlrelay/bridge/__main__.py +89 -0
  4. ctrlrelay/bridge/protocol.py +75 -0
  5. ctrlrelay/bridge/server.py +740 -0
  6. ctrlrelay/bridge/telegram_handler.py +136 -0
  7. ctrlrelay/cli.py +3138 -0
  8. ctrlrelay/core/__init__.py +54 -0
  9. ctrlrelay/core/audit.py +257 -0
  10. ctrlrelay/core/checkpoint.py +155 -0
  11. ctrlrelay/core/config.py +778 -0
  12. ctrlrelay/core/dispatcher.py +372 -0
  13. ctrlrelay/core/github.py +432 -0
  14. ctrlrelay/core/network.py +277 -0
  15. ctrlrelay/core/obs.py +118 -0
  16. ctrlrelay/core/poller.py +920 -0
  17. ctrlrelay/core/pr_verifier.py +230 -0
  18. ctrlrelay/core/pr_watcher.py +121 -0
  19. ctrlrelay/core/question_expiry.py +193 -0
  20. ctrlrelay/core/scheduler.py +337 -0
  21. ctrlrelay/core/state.py +642 -0
  22. ctrlrelay/core/worktree.py +893 -0
  23. ctrlrelay/dashboard/__init__.py +5 -0
  24. ctrlrelay/dashboard/client.py +159 -0
  25. ctrlrelay/gh_protocol.py +47 -0
  26. ctrlrelay/install.py +203 -0
  27. ctrlrelay/personalization/__init__.py +26 -0
  28. ctrlrelay/personalization/manager.py +1183 -0
  29. ctrlrelay/personalization/paths.py +143 -0
  30. ctrlrelay/pipelines/__init__.py +15 -0
  31. ctrlrelay/pipelines/base.py +50 -0
  32. ctrlrelay/pipelines/dev.py +1367 -0
  33. ctrlrelay/pipelines/post_merge.py +411 -0
  34. ctrlrelay/pipelines/secops.py +1018 -0
  35. ctrlrelay/pipelines/task.py +713 -0
  36. ctrlrelay/setup.py +617 -0
  37. ctrlrelay/templates/__init__.py +0 -0
  38. ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
  39. ctrlrelay/templates/launchd/__init__.py +0 -0
  40. ctrlrelay/templates/launchd/bridge.plist.template +37 -0
  41. ctrlrelay/templates/launchd/poller.plist.template +44 -0
  42. ctrlrelay/templates/systemd/__init__.py +0 -0
  43. ctrlrelay/templates/systemd/bridge.service.template +20 -0
  44. ctrlrelay/templates/systemd/poller.service.template +25 -0
  45. ctrlrelay/transports/__init__.py +34 -0
  46. ctrlrelay/transports/base.py +74 -0
  47. ctrlrelay/transports/file_mock.py +103 -0
  48. ctrlrelay/transports/socket_client.py +298 -0
  49. ctrlrelay-0.10.0.dist-info/METADATA +254 -0
  50. ctrlrelay-0.10.0.dist-info/RECORD +53 -0
  51. ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
  52. ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
  53. 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
+ )