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
ctrlrelay/cli.py ADDED
@@ -0,0 +1,3138 @@
1
+ """CLI entry point for ctrlrelay."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.markup import escape as rich_escape
9
+ from rich.table import Table
10
+
11
+ from ctrlrelay import __version__
12
+ from ctrlrelay.core.config import ConfigError, load_config, resolve_config_path
13
+ from ctrlrelay.core.github import GitHubCLI, GitHubError
14
+ from ctrlrelay.core.pr_verifier import PRVerifier
15
+ from ctrlrelay.core.question_expiry import expire_stale_questions
16
+
17
+ # Startup probe cap. `GitHubCLI` uses 60s for ordinary calls; this one
18
+ # gates daemon startup, so a hung gh must not wedge it indefinitely.
19
+ _GH_PROBE_TIMEOUT_SECONDS = 60
20
+
21
+ app = typer.Typer(
22
+ name="ctrlrelay",
23
+ help="Local-first orchestrator for Claude Code across multiple GitHub repos.",
24
+ no_args_is_help=True,
25
+ )
26
+ console = Console()
27
+
28
+
29
+ def version_callback(value: bool) -> None:
30
+ if value:
31
+ console.print(f"ctrlrelay version {__version__}")
32
+ raise typer.Exit()
33
+
34
+
35
+ @app.callback()
36
+ def main(
37
+ version: bool = typer.Option(
38
+ False,
39
+ "--version",
40
+ "-v",
41
+ callback=version_callback,
42
+ is_eager=True,
43
+ help="Show version and exit.",
44
+ ),
45
+ ) -> None:
46
+ """ctrlrelay orchestrator CLI."""
47
+
48
+
49
+ def _resolve_config_or_exit(config_path: str | None) -> Path:
50
+ """Resolve --config (or auto-discover) and exit with a friendly error if missing."""
51
+ try:
52
+ return resolve_config_path(config_path)
53
+ except ConfigError as e:
54
+ console.print(f"[red]Error loading config:[/red] {e}")
55
+ raise typer.Exit(1)
56
+
57
+
58
+ # Subcommand groups
59
+ config_app = typer.Typer(help="Configuration commands.")
60
+ app.add_typer(config_app, name="config")
61
+
62
+
63
+ @config_app.command("validate")
64
+ def config_validate(
65
+ config_path: str | None = typer.Option(
66
+ None,
67
+ "--config",
68
+ "-c",
69
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
70
+ ),
71
+ ) -> None:
72
+ """Validate orchestrator.yaml configuration."""
73
+ path = _resolve_config_or_exit(config_path)
74
+
75
+ if not path.exists():
76
+ console.print(f"[red]Error:[/red] Config file not found: {path}")
77
+ raise typer.Exit(1)
78
+
79
+ try:
80
+ config = load_config(path)
81
+ except ConfigError as e:
82
+ console.print(f"[red]Validation failed:[/red] {e}")
83
+ raise typer.Exit(1)
84
+
85
+ console.print(f"[green]✓[/green] Config valid: {path}")
86
+ console.print(f" Node ID: {config.node_id}")
87
+ console.print(f" Timezone: {config.timezone}")
88
+ console.print(f" Transport: {config.transport.type.value}")
89
+ console.print(f" Repos: {len(config.repos)}")
90
+
91
+
92
+ @config_app.command("repos")
93
+ def config_repos(
94
+ config_path: str | None = typer.Option(
95
+ None,
96
+ "--config",
97
+ "-c",
98
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
99
+ ),
100
+ ) -> None:
101
+ """List configured repositories."""
102
+ path = _resolve_config_or_exit(config_path)
103
+
104
+ try:
105
+ config = load_config(path)
106
+ except ConfigError as e:
107
+ console.print(f"[red]Error:[/red] {e}")
108
+ raise typer.Exit(1)
109
+
110
+ if not config.repos:
111
+ console.print("[yellow]No repositories configured.[/yellow]")
112
+ return
113
+
114
+ table = Table(title="Configured Repositories")
115
+ table.add_column("Name", style="cyan")
116
+ table.add_column("Path", style="dim")
117
+ table.add_column("Deploy", style="green")
118
+
119
+ for repo in config.repos:
120
+ deploy = repo.deploy.provider if repo.deploy else "-"
121
+ table.add_row(repo.name, str(repo.local_path), deploy)
122
+
123
+ console.print(table)
124
+
125
+
126
+ # Skills subcommand group
127
+ skills_app = typer.Typer(help="Skill management commands.")
128
+ app.add_typer(skills_app, name="skills")
129
+
130
+
131
+ def _resolve_skills_dir(skills_path: str | None, config_path: str | None) -> Path:
132
+ """Resolve skills directory from flag or config."""
133
+ if skills_path is not None:
134
+ skills_dir = Path(skills_path).expanduser().resolve()
135
+ else:
136
+ try:
137
+ config = load_config(resolve_config_path(config_path))
138
+ skills_dir = config.paths.skills.expanduser().resolve()
139
+ except ConfigError as e:
140
+ console.print(f"[red]Error loading config:[/red] {e}")
141
+ console.print("Use --path to specify skills directory directly.")
142
+ raise typer.Exit(1)
143
+
144
+ if not skills_dir.exists():
145
+ console.print(f"[red]Skills directory not found:[/red] {skills_dir}")
146
+ raise typer.Exit(1)
147
+
148
+ return skills_dir
149
+
150
+
151
+ @skills_app.command("audit")
152
+ def skills_audit(
153
+ skills_path: str = typer.Option(
154
+ None,
155
+ "--path",
156
+ "-p",
157
+ help="Path to skills directory (default: from config)",
158
+ ),
159
+ config_path: str | None = typer.Option(
160
+ None,
161
+ "--config",
162
+ "-c",
163
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
164
+ ),
165
+ ) -> None:
166
+ """Audit skills for orchestrator readiness."""
167
+ from ctrlrelay.core.audit import audit_all, format_report
168
+
169
+ skills_dir = _resolve_skills_dir(skills_path, config_path)
170
+
171
+ console.print(f"Auditing skills in: {skills_dir}\n")
172
+
173
+ audits = audit_all(skills_dir)
174
+
175
+ if not audits:
176
+ console.print("[yellow]No skills found.[/yellow]")
177
+ return
178
+
179
+ report = format_report(audits)
180
+ console.print(report)
181
+
182
+ # Exit with error if any skills not ready
183
+ if not all(a.passed for a in audits):
184
+ raise typer.Exit(1)
185
+
186
+
187
+ @skills_app.command("list")
188
+ def skills_list(
189
+ skills_path: str = typer.Option(
190
+ None,
191
+ "--path",
192
+ "-p",
193
+ help="Path to skills directory (default: from config)",
194
+ ),
195
+ config_path: str | None = typer.Option(
196
+ None,
197
+ "--config",
198
+ "-c",
199
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
200
+ ),
201
+ ) -> None:
202
+ """List available skills."""
203
+ from ctrlrelay.core.audit import discover_skills
204
+
205
+ skills_dir = _resolve_skills_dir(skills_path, config_path)
206
+
207
+ skills = discover_skills(skills_dir)
208
+
209
+ if not skills:
210
+ console.print("[yellow]No skills found.[/yellow]")
211
+ return
212
+
213
+ table = Table(title="Available Skills")
214
+ table.add_column("Name", style="cyan")
215
+ table.add_column("Path", style="dim")
216
+
217
+ for skill in skills:
218
+ table.add_row(skill.name, str(skill.path))
219
+
220
+ console.print(table)
221
+
222
+
223
+ # Bridge subcommand group
224
+ bridge_app = typer.Typer(help="Telegram bridge commands.")
225
+ app.add_typer(bridge_app, name="bridge")
226
+
227
+
228
+ def _get_socket_path(config_path: str | None) -> Path:
229
+ """Get socket path from config."""
230
+ try:
231
+ config = load_config(resolve_config_path(config_path))
232
+ if config.transport.telegram:
233
+ return config.transport.telegram.socket_path.expanduser().resolve()
234
+ except ConfigError:
235
+ pass
236
+ return Path("~/.ctrlrelay/ctrlrelay.sock").expanduser().resolve()
237
+
238
+
239
+ def _get_bridge_pid_file(socket_path: Path) -> Path:
240
+ """Get PID file path for bridge process."""
241
+ return socket_path.with_suffix(".pid")
242
+
243
+
244
+ @bridge_app.command("start")
245
+ def bridge_start(
246
+ config_path: str | None = typer.Option(
247
+ None,
248
+ "--config",
249
+ "-c",
250
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
251
+ ),
252
+ foreground: bool = typer.Option(
253
+ False,
254
+ "--foreground",
255
+ "-F",
256
+ help="Run in the foreground (for launchd/systemd/debugging). Default is to daemonize.",
257
+ ),
258
+ ) -> None:
259
+ """Start the Telegram bridge.
260
+
261
+ Daemonizes by default so the terminal returns to you. Pass --foreground
262
+ under a process supervisor (launchd Type=simple, systemd Type=simple) or
263
+ when debugging interactively.
264
+ """
265
+ import os
266
+ import subprocess
267
+ import sys
268
+
269
+ try:
270
+ config = load_config(resolve_config_path(config_path))
271
+ except ConfigError as e:
272
+ console.print(f"[red]Error loading config:[/red] {e}")
273
+ raise typer.Exit(1)
274
+
275
+ if config.transport.type.value != "telegram":
276
+ console.print("[yellow]Transport is not set to 'telegram' in config.[/yellow]")
277
+ console.print("Set transport.type: telegram to use the bridge.")
278
+ raise typer.Exit(1)
279
+
280
+ telegram_config = config.transport.telegram
281
+ if not telegram_config:
282
+ console.print("[red]Telegram config not found.[/red]")
283
+ raise typer.Exit(1)
284
+
285
+ socket_path = telegram_config.socket_path.expanduser().resolve()
286
+ pid_file = _get_bridge_pid_file(socket_path)
287
+
288
+ if pid_file.exists():
289
+ try:
290
+ pid = int(pid_file.read_text().strip())
291
+ os.kill(pid, 0)
292
+ console.print(f"[yellow]Bridge already running (PID {pid})[/yellow]")
293
+ raise typer.Exit(1)
294
+ except (ProcessLookupError, ValueError):
295
+ pid_file.unlink(missing_ok=True)
296
+
297
+ bot_token = os.environ.get(telegram_config.bot_token_env)
298
+ if not bot_token:
299
+ env_var = telegram_config.bot_token_env
300
+ console.print(f"[red]Bot token not found.[/red] Set {env_var} environment variable.")
301
+ raise typer.Exit(1)
302
+
303
+ pid_file.parent.mkdir(parents=True, exist_ok=True)
304
+
305
+ if foreground:
306
+ import asyncio
307
+ import signal
308
+
309
+ from ctrlrelay.bridge import BridgeServer
310
+
311
+ # Install early SIGTERM/SIGINT handlers so a supervisor stop between
312
+ # now and loop.add_signal_handler below still runs the `finally`
313
+ # that unlinks the PID file. loop.add_signal_handler replaces them
314
+ # once the asyncio loop is running.
315
+ def _raise_systemexit_on_signal(sig: int, _frame: object) -> None:
316
+ raise SystemExit(0)
317
+
318
+ for _sig in (signal.SIGTERM, signal.SIGINT):
319
+ signal.signal(_sig, _raise_systemexit_on_signal)
320
+
321
+ pid_file.write_text(str(os.getpid()))
322
+ console.print(f"Starting bridge on {socket_path}")
323
+ console.print("Press Ctrl+C to stop")
324
+
325
+ # Open the state DB so the bridge can route orphan Telegram replies
326
+ # to persisted BLOCKED sessions in pending_resumes. Both daemons
327
+ # share ~/.ctrlrelay/state.db — SQLite's WAL mode handles concurrent
328
+ # readers/writers for the low contention we see here.
329
+ from ctrlrelay.core.state import StateDB
330
+ state_db = StateDB(config.paths.state_db)
331
+
332
+ server = BridgeServer(
333
+ socket_path=socket_path,
334
+ bot_token=bot_token,
335
+ chat_id=telegram_config.chat_id,
336
+ state_db=state_db,
337
+ )
338
+
339
+ loop = asyncio.new_event_loop()
340
+ try:
341
+ asyncio.set_event_loop(loop)
342
+
343
+ async def _run_server() -> None:
344
+ # Wrap start() in a finally that awaits stop() so the loop
345
+ # can't close before _telegram.close() and the socket unlink
346
+ # have actually completed. Scheduling stop() as a bare task
347
+ # in the signal handler would not guarantee that ordering.
348
+ try:
349
+ await server.start()
350
+ finally:
351
+ await server.stop()
352
+
353
+ main_task = loop.create_task(_run_server())
354
+
355
+ def _handle_stop(sig: int) -> None:
356
+ main_task.cancel()
357
+
358
+ for sig in (signal.SIGTERM, signal.SIGINT):
359
+ loop.add_signal_handler(sig, _handle_stop, sig)
360
+
361
+ try:
362
+ loop.run_until_complete(main_task)
363
+ except asyncio.CancelledError:
364
+ pass
365
+ finally:
366
+ loop.close()
367
+ try:
368
+ state_db.close()
369
+ except Exception:
370
+ pass
371
+ pid_file.unlink(missing_ok=True)
372
+ else:
373
+ # Pass the token via environment, never argv. Putting it on the command
374
+ # line would expose it to anyone who can read `ps` / /proc/*/cmdline.
375
+ cmd = [
376
+ sys.executable,
377
+ "-m",
378
+ "ctrlrelay.bridge",
379
+ "--socket-path",
380
+ str(socket_path),
381
+ "--bot-token-env",
382
+ telegram_config.bot_token_env,
383
+ "--chat-id",
384
+ str(telegram_config.chat_id),
385
+ "--state-db",
386
+ str(config.paths.state_db),
387
+ ]
388
+ proc = subprocess.Popen(
389
+ cmd,
390
+ stdout=subprocess.DEVNULL,
391
+ stderr=subprocess.DEVNULL,
392
+ start_new_session=True,
393
+ )
394
+ # Claim the PID file BEFORE the liveness probe; a second concurrent
395
+ # `bridge start` in the 1-second window would otherwise see no PID
396
+ # file, spawn its own child, and both would rebind the shared socket.
397
+ pid_file.write_text(str(proc.pid))
398
+ try:
399
+ proc.wait(timeout=1.0)
400
+ except subprocess.TimeoutExpired:
401
+ console.print(f"[green]Bridge started (PID {proc.pid})[/green]")
402
+ return
403
+ # Child exited within 1s. Zero = clean no-op; non-zero = crash.
404
+ pid_file.unlink(missing_ok=True)
405
+ if proc.returncode == 0:
406
+ console.print(
407
+ "[yellow]Bridge exited immediately with no work to do.[/yellow]"
408
+ )
409
+ return
410
+ console.print(
411
+ f"[red]Bridge failed to start[/red] "
412
+ f"(child exited with code {proc.returncode})"
413
+ )
414
+ raise typer.Exit(1)
415
+
416
+
417
+ @bridge_app.command("stop")
418
+ def bridge_stop(
419
+ config_path: str | None = typer.Option(
420
+ None,
421
+ "--config",
422
+ "-c",
423
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
424
+ ),
425
+ ) -> None:
426
+ """Stop the Telegram bridge."""
427
+ import os
428
+ import signal
429
+
430
+ socket_path = _get_socket_path(config_path)
431
+ pid_file = _get_bridge_pid_file(socket_path)
432
+
433
+ if not pid_file.exists():
434
+ console.print("[yellow]Bridge not running (no PID file)[/yellow]")
435
+ return
436
+
437
+ try:
438
+ pid = int(pid_file.read_text().strip())
439
+ os.kill(pid, signal.SIGTERM)
440
+ console.print(f"[green]Stopped bridge (PID {pid})[/green]")
441
+ pid_file.unlink(missing_ok=True)
442
+ except ProcessLookupError:
443
+ console.print("[yellow]Bridge process not found[/yellow]")
444
+ pid_file.unlink(missing_ok=True)
445
+ except ValueError:
446
+ console.print("[red]Invalid PID file[/red]")
447
+ pid_file.unlink(missing_ok=True)
448
+
449
+
450
+ @bridge_app.command("status")
451
+ def bridge_status(
452
+ config_path: str | None = typer.Option(
453
+ None,
454
+ "--config",
455
+ "-c",
456
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
457
+ ),
458
+ ) -> None:
459
+ """Check bridge status."""
460
+ import os
461
+
462
+ socket_path = _get_socket_path(config_path)
463
+ pid_file = _get_bridge_pid_file(socket_path)
464
+
465
+ if pid_file.exists():
466
+ try:
467
+ pid = int(pid_file.read_text().strip())
468
+ os.kill(pid, 0)
469
+ console.print(f"[green]Bridge running (PID {pid})[/green]")
470
+ console.print(f"Socket: {socket_path}")
471
+ return
472
+ except (ProcessLookupError, ValueError):
473
+ pass
474
+
475
+ if socket_path.exists():
476
+ console.print("[yellow]Socket exists but no PID file[/yellow]")
477
+ console.print(
478
+ "[dim]The bridge may be running under a supervisor that pre-dates "
479
+ "the PID-file change — restart it to refresh state.[/dim]"
480
+ )
481
+ console.print(f"Socket: {socket_path}")
482
+ raise typer.Exit(1)
483
+
484
+ console.print("[dim]Bridge not running[/dim]")
485
+ raise typer.Exit(1)
486
+
487
+
488
+ @bridge_app.command("test")
489
+ def bridge_test(
490
+ message: str = typer.Option(
491
+ "Test message from ctrlrelay bridge",
492
+ "--message",
493
+ "-m",
494
+ help="Message to send",
495
+ ),
496
+ config_path: str | None = typer.Option(
497
+ None,
498
+ "--config",
499
+ "-c",
500
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
501
+ ),
502
+ ) -> None:
503
+ """Send a test message to verify bridge is working."""
504
+ import asyncio
505
+
506
+ socket_path = _get_socket_path(config_path)
507
+
508
+ if not socket_path.exists():
509
+ console.print("[red]Bridge not running.[/red] Start it with: ctrlrelay bridge start")
510
+ raise typer.Exit(1)
511
+
512
+ async def send_test():
513
+ from ctrlrelay.transports import SocketTransport
514
+
515
+ # Send-only helper: never calls ask(), so the operator's
516
+ # ask_timeout_seconds is irrelevant here.
517
+ transport = SocketTransport(socket_path)
518
+ try:
519
+ await transport.connect()
520
+ await transport.send(message)
521
+ console.print("[green]Message sent successfully![/green]")
522
+ finally:
523
+ await transport.close()
524
+
525
+ try:
526
+ asyncio.run(send_test())
527
+ except Exception as e:
528
+ console.print(f"[red]Failed to send message:[/red] {e}")
529
+ raise typer.Exit(1)
530
+
531
+
532
+ # Run subcommand group
533
+ run_app = typer.Typer(help="Pipeline execution commands.")
534
+ app.add_typer(run_app, name="run")
535
+
536
+
537
+ @run_app.command("secops")
538
+ def run_secops(
539
+ config_path: str | None = typer.Option(
540
+ None,
541
+ "--config",
542
+ "-c",
543
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
544
+ ),
545
+ repo: str = typer.Option(
546
+ None,
547
+ "--repo",
548
+ "-r",
549
+ help="Run on specific repo only",
550
+ ),
551
+ ) -> None:
552
+ """Run secops pipeline on configured repos."""
553
+ import asyncio
554
+
555
+ from ctrlrelay.core.dispatcher import make_agent_dispatcher
556
+ from ctrlrelay.core.github import GitHubCLI
557
+ from ctrlrelay.core.state import StateDB
558
+ from ctrlrelay.core.worktree import WorktreeManager
559
+ from ctrlrelay.dashboard.client import DashboardClient
560
+ from ctrlrelay.pipelines.secops import run_secops_all
561
+
562
+ path = _resolve_config_or_exit(config_path)
563
+
564
+ try:
565
+ config = load_config(path)
566
+ except ConfigError as e:
567
+ console.print(f"[red]Error loading config:[/red] {e}")
568
+ raise typer.Exit(1)
569
+
570
+ repos = config.repos
571
+ if repo:
572
+ repos = [r for r in repos if r.name == repo]
573
+ if not repos:
574
+ console.print(f"[red]Repo not found:[/red] {repo}")
575
+ raise typer.Exit(1)
576
+
577
+ if not repos:
578
+ console.print("[yellow]No repos configured.[/yellow]")
579
+ return
580
+
581
+ db = StateDB(config.paths.state_db)
582
+ dispatcher = make_agent_dispatcher(config.agent)
583
+ github = GitHubCLI()
584
+ worktree = WorktreeManager(
585
+ worktrees_dir=config.paths.worktrees,
586
+ bare_repos_dir=config.paths.bare_repos,
587
+ )
588
+
589
+ dashboard = None
590
+ if config.dashboard.enabled and config.dashboard.url:
591
+ import os
592
+ token = os.environ.get(config.dashboard.auth_token_env, "")
593
+ if token:
594
+ dashboard = DashboardClient(
595
+ url=config.dashboard.url,
596
+ auth_token=token,
597
+ node_id=config.node_id,
598
+ queue_dir=config.paths.state_db.parent / "event_queue",
599
+ )
600
+
601
+ console.print(f"Running secops on {len(repos)} repo(s)...")
602
+
603
+ async def _run():
604
+ # Build a transport so secops blocked questions reach Telegram.
605
+ # Without this, agents that end BLOCKED_NEEDS_INPUT silently land
606
+ # in the DB+pending_resumes and the operator never sees the
607
+ # question (mirror of the wiring in the scheduled path).
608
+ transport = None
609
+ if (
610
+ config.transport.type.value == "telegram"
611
+ and config.transport.telegram
612
+ ):
613
+ from ctrlrelay.transports import SocketTransport
614
+ sock = config.transport.telegram.socket_path.expanduser().resolve()
615
+ if sock.exists():
616
+ try:
617
+ candidate = SocketTransport(
618
+ sock,
619
+ ask_timeout_seconds=(
620
+ config.transport.telegram.ask_timeout_seconds
621
+ ),
622
+ )
623
+ await candidate.connect()
624
+ transport = candidate
625
+ except Exception as e:
626
+ console.print(
627
+ f"[yellow]Bridge transport connect failed ({e}) — "
628
+ f"running without notifications. Blocked sessions "
629
+ f"will land in pending_resumes for later resume.[/yellow]"
630
+ )
631
+
632
+ return await run_secops_all(
633
+ repos=repos,
634
+ dispatcher=dispatcher,
635
+ github=github,
636
+ worktree=worktree,
637
+ dashboard=dashboard,
638
+ state_db=db,
639
+ transport=transport,
640
+ contexts_dir=config.paths.contexts,
641
+ )
642
+
643
+ try:
644
+ results = asyncio.run(_run())
645
+ except Exception as e:
646
+ console.print(f"[red]Error:[/red] {e}")
647
+ raise typer.Exit(1)
648
+ finally:
649
+ db.close()
650
+
651
+ success_count = sum(1 for r in results if r.success)
652
+ console.print(f"\n[bold]Results:[/bold] {success_count}/{len(results)} succeeded")
653
+
654
+ for result in results:
655
+ status = "[green]OK[/green]" if result.success else "[red]FAIL[/red]"
656
+ console.print(f" {status} {result.summary}")
657
+ # Surface the blocking question / error text so an operator
658
+ # running secops at the CLI doesn't have to dig into state.db
659
+ # to see what the agent was asking. The scheduler closure
660
+ # already relays these via Telegram; this is the manual path.
661
+ if result.blocked and result.question:
662
+ console.print(f" [yellow]Question:[/yellow] {result.question}")
663
+ elif not result.success and result.error:
664
+ console.print(f" [red]Error:[/red] {result.error}")
665
+
666
+ if not all(r.success for r in results):
667
+ raise typer.Exit(1)
668
+
669
+
670
+ @run_app.command("dev")
671
+ def run_dev(
672
+ issue: int = typer.Option(
673
+ ...,
674
+ "--issue",
675
+ "-i",
676
+ help="GitHub issue number to implement",
677
+ ),
678
+ repo: str = typer.Option(
679
+ None,
680
+ "--repo",
681
+ "-r",
682
+ help="Run on specific repo only",
683
+ ),
684
+ config_path: str | None = typer.Option(
685
+ None,
686
+ "--config",
687
+ "-c",
688
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
689
+ ),
690
+ ) -> None:
691
+ """Run dev pipeline for a GitHub issue."""
692
+ import asyncio
693
+
694
+ from ctrlrelay.core.dispatcher import make_agent_dispatcher
695
+ from ctrlrelay.core.github import GitHubCLI
696
+ from ctrlrelay.core.state import StateDB
697
+ from ctrlrelay.core.worktree import WorktreeManager
698
+ from ctrlrelay.pipelines.dev import run_dev_issue
699
+
700
+ path = _resolve_config_or_exit(config_path)
701
+
702
+ try:
703
+ config = load_config(path)
704
+ except ConfigError as e:
705
+ console.print(f"[red]Error loading config:[/red] {e}")
706
+ raise typer.Exit(1)
707
+
708
+ repos = config.repos
709
+ if repo:
710
+ repos = [r for r in repos if r.name == repo]
711
+ if not repos:
712
+ console.print(f"[red]Repo not found:[/red] {repo}")
713
+ raise typer.Exit(1)
714
+
715
+ if not repos:
716
+ console.print("[yellow]No repos configured.[/yellow]")
717
+ return
718
+
719
+ if len(repos) > 1 and not repo:
720
+ console.print(
721
+ "[red]Error:[/red] Multiple repos configured. "
722
+ "Use --repo to specify which one."
723
+ )
724
+ raise typer.Exit(1)
725
+
726
+ repo_config = repos[0]
727
+ branch_template = repo_config.dev_branch_template
728
+
729
+ db = StateDB(config.paths.state_db)
730
+ dispatcher = make_agent_dispatcher(config.agent)
731
+ github = GitHubCLI()
732
+ worktree = WorktreeManager(
733
+ worktrees_dir=config.paths.worktrees,
734
+ bare_repos_dir=config.paths.bare_repos,
735
+ )
736
+
737
+ dashboard = None
738
+ if config.dashboard.enabled and config.dashboard.url:
739
+ import os
740
+ token = os.environ.get(config.dashboard.auth_token_env, "")
741
+ if token:
742
+ from ctrlrelay.dashboard.client import DashboardClient
743
+ dashboard = DashboardClient(
744
+ url=config.dashboard.url,
745
+ auth_token=token,
746
+ node_id=config.node_id,
747
+ queue_dir=config.paths.state_db.parent / "event_queue",
748
+ )
749
+
750
+ console.print(f"Running dev pipeline for issue #{issue} on {repo_config.name}...")
751
+
752
+ async def _run():
753
+ return await run_dev_issue(
754
+ repo=repo_config.name,
755
+ issue_number=issue,
756
+ branch_template=branch_template,
757
+ dispatcher=dispatcher,
758
+ github=github,
759
+ worktree=worktree,
760
+ dashboard=dashboard,
761
+ state_db=db,
762
+ transport=None,
763
+ contexts_dir=config.paths.contexts,
764
+ ci_wait_timeout_seconds=repo_config.automation.ci_wait_timeout_seconds,
765
+ )
766
+
767
+ try:
768
+ result = asyncio.run(_run())
769
+ except Exception as e:
770
+ console.print(f"[red]Error:[/red] {e}")
771
+ raise typer.Exit(1)
772
+ finally:
773
+ db.close()
774
+
775
+ if result.success:
776
+ pr_url = result.outputs.get("pr_url", "") if result.outputs else ""
777
+ console.print(f"[green]Success:[/green] {result.summary}")
778
+ if pr_url:
779
+ console.print(f" PR: {pr_url}")
780
+ elif result.blocked:
781
+ console.print(f"[blue]Blocked:[/blue] {result.summary}")
782
+ if result.question:
783
+ console.print(f" Question: {result.question}")
784
+ raise typer.Exit(1)
785
+ else:
786
+ console.print(f"[red]Failed:[/red] {result.summary}")
787
+ if result.error:
788
+ console.print(f" Error: {result.error}")
789
+ raise typer.Exit(1)
790
+
791
+
792
+ # CI subcommand group
793
+ # Exists so the dev pipeline has a correct, exit-code-driven helper to wait
794
+ # on PR checks. Claude used to improvise bash `until gh pr checks` loops that
795
+ # were inverted-semantics and swallowed exit codes (see issue #85), burning
796
+ # the whole session timeout on PRs that had already gone green.
797
+ ci_app = typer.Typer(help="CI helpers.")
798
+ app.add_typer(ci_app, name="ci")
799
+
800
+
801
+ @ci_app.command("wait")
802
+ def ci_wait(
803
+ pr: int = typer.Option(..., "--pr", "-p", help="PR number to wait on"),
804
+ repo: str = typer.Option(..., "--repo", "-r", help="Repository as owner/name"),
805
+ timeout: int = typer.Option(
806
+ 600,
807
+ "--timeout",
808
+ "-t",
809
+ help="Hard timeout in seconds before giving up (exit 2).",
810
+ ),
811
+ interval: int = typer.Option(
812
+ 15,
813
+ "--interval",
814
+ "-i",
815
+ help="Poll interval in seconds.",
816
+ ),
817
+ ) -> None:
818
+ """Wait for a PR's CI checks to finish.
819
+
820
+ Exit codes:
821
+ 0 — all checks passed (or no CI configured)
822
+ 1 — at least one check failed / cancelled / timed_out
823
+ 2 — hard timeout hit while checks were still pending
824
+ """
825
+ import asyncio
826
+
827
+ github = GitHubCLI()
828
+ verifier = PRVerifier(
829
+ github=github,
830
+ poll_interval=interval,
831
+ check_timeout=timeout,
832
+ )
833
+
834
+ try:
835
+ checks = asyncio.run(
836
+ verifier.wait_for_checks(repo, pr, timeout=timeout)
837
+ )
838
+ except (GitHubError, asyncio.TimeoutError) as e:
839
+ # wait_for_checks already retries transient errors internally;
840
+ # this except is defense-in-depth for any leak past the retry
841
+ # window (e.g. repeated timeouts that exhaust the deadline
842
+ # mid-call). Surface a clean message instead of a traceback.
843
+ console.print(
844
+ f"[red]gh error ({type(e).__name__}):[/red] {e}"
845
+ )
846
+ raise typer.Exit(1) from e
847
+
848
+ pending = [c for c in checks if c.get("bucket") == "pending"]
849
+ failing = [
850
+ c for c in checks
851
+ if c.get("bucket") not in ("pending", "pass", "skipping")
852
+ ]
853
+
854
+ if failing:
855
+ names = ", ".join(
856
+ f"{c.get('name', '?')}={c.get('state') or c.get('bucket')}"
857
+ for c in failing
858
+ )
859
+ console.print(f"[red]CI failing:[/red] {names}")
860
+ raise typer.Exit(1)
861
+
862
+ if pending:
863
+ names = ", ".join(c.get("name", "?") for c in pending)
864
+ console.print(
865
+ f"[yellow]CI still running after {timeout}s:[/yellow] {names}"
866
+ )
867
+ raise typer.Exit(2)
868
+
869
+ if checks:
870
+ console.print(f"[green]All {len(checks)} check(s) passed.[/green]")
871
+ else:
872
+ console.print("[green]No CI checks configured — treating as pass.[/green]")
873
+
874
+
875
+ # Poller subcommand group
876
+ poller_app = typer.Typer(help="Issue poller commands.")
877
+ app.add_typer(poller_app, name="poller")
878
+
879
+
880
+ def _get_poller_pid_file(config_path: str | None) -> Path:
881
+ """Get PID file path for poller process."""
882
+ try:
883
+ config = load_config(resolve_config_path(config_path))
884
+ return config.paths.state_db.parent / "poller.pid"
885
+ except ConfigError:
886
+ pass
887
+ return Path("~/.ctrlrelay/poller.pid").expanduser().resolve()
888
+
889
+
890
+ @poller_app.command("start")
891
+ def poller_start(
892
+ config_path: str | None = typer.Option(
893
+ None,
894
+ "--config",
895
+ "-c",
896
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
897
+ ),
898
+ foreground: bool = typer.Option(
899
+ False,
900
+ "--foreground",
901
+ "-F",
902
+ help="Run in the foreground (for launchd/systemd/debugging). Default is to daemonize.",
903
+ ),
904
+ interval: int = typer.Option(
905
+ 300,
906
+ "--interval",
907
+ "-i",
908
+ help="Polling interval in seconds",
909
+ ),
910
+ ) -> None:
911
+ """Start the issue poller.
912
+
913
+ Daemonizes by default so the terminal returns to you. Pass --foreground
914
+ under a process supervisor (launchd Type=simple, systemd Type=simple) or
915
+ when debugging interactively.
916
+ """
917
+ import asyncio
918
+ import os
919
+ import signal
920
+ import subprocess
921
+ import sys
922
+
923
+ resolved_config = _resolve_config_or_exit(config_path)
924
+
925
+ try:
926
+ config = load_config(resolved_config)
927
+ except ConfigError as e:
928
+ console.print(f"[red]Error loading config:[/red] {e}")
929
+ raise typer.Exit(1)
930
+
931
+ pid_file = _get_poller_pid_file(str(resolved_config))
932
+ if pid_file.exists():
933
+ try:
934
+ pid = int(pid_file.read_text().strip())
935
+ if pid == os.getpid():
936
+ # The daemon parent wrote our own PID here before spawning us
937
+ # as the `--foreground` child. Don't treat it as a conflict.
938
+ pass
939
+ else:
940
+ os.kill(pid, 0)
941
+ console.print(f"[yellow]Poller already running (PID {pid})[/yellow]")
942
+ raise typer.Exit(1)
943
+ except (ProcessLookupError, ValueError):
944
+ pid_file.unlink(missing_ok=True)
945
+ pid_file.parent.mkdir(parents=True, exist_ok=True)
946
+
947
+ if not foreground:
948
+ cmd = [
949
+ sys.executable,
950
+ "-m",
951
+ "ctrlrelay.cli",
952
+ "poller",
953
+ "start",
954
+ "--config",
955
+ str(resolved_config),
956
+ "--interval",
957
+ str(interval),
958
+ "--foreground",
959
+ ]
960
+ proc = subprocess.Popen(
961
+ cmd,
962
+ stdout=subprocess.DEVNULL,
963
+ stderr=subprocess.DEVNULL,
964
+ start_new_session=True,
965
+ )
966
+ # Claim the PID file BEFORE the liveness probe; otherwise a second
967
+ # concurrent `start` in the 1-second window would see no PID file and
968
+ # spawn a duplicate poller.
969
+ pid_file.write_text(str(proc.pid))
970
+ try:
971
+ proc.wait(timeout=1.0)
972
+ except subprocess.TimeoutExpired:
973
+ console.print(f"[green]Poller started (PID {proc.pid})[/green]")
974
+ return
975
+ # Child exited. Zero = clean no-op (e.g. `repos: []`); non-zero = crash.
976
+ pid_file.unlink(missing_ok=True)
977
+ if proc.returncode == 0:
978
+ console.print(
979
+ "[yellow]Poller exited immediately with no work to do "
980
+ "(check `repos:` in your config).[/yellow]"
981
+ )
982
+ return
983
+ console.print(
984
+ f"[red]Poller failed to start[/red] "
985
+ f"(child exited with code {proc.returncode})"
986
+ )
987
+ raise typer.Exit(1)
988
+
989
+ # Install SIGTERM/SIGINT handlers BEFORE any startup work so a supervisor
990
+ # stop during `gh api user` / `seed_current()` still unwinds through the
991
+ # `finally` that unlinks poller.pid. Converting the signal into SystemExit
992
+ # lets Python's normal unwind run `finally` blocks. Once the asyncio loop
993
+ # is up below, `loop.add_signal_handler` overrides these to drive a
994
+ # graceful cancel of the poll loop.
995
+ def _raise_systemexit_on_signal(sig: int, _frame: object) -> None:
996
+ raise SystemExit(0)
997
+
998
+ for sig in (signal.SIGTERM, signal.SIGINT):
999
+ signal.signal(sig, _raise_systemexit_on_signal)
1000
+
1001
+ pid_file.write_text(str(os.getpid()))
1002
+ try:
1003
+ from ctrlrelay.core.dispatcher import make_agent_dispatcher
1004
+ from ctrlrelay.core.github import GitHubCLI
1005
+ from ctrlrelay.core.poller import IssuePoller, run_poll_loop
1006
+ from ctrlrelay.core.scheduler import make_scheduler
1007
+ from ctrlrelay.core.state import StateDB
1008
+ from ctrlrelay.core.worktree import WorktreeManager
1009
+ from ctrlrelay.pipelines.dev import run_dev_issue
1010
+ from ctrlrelay.pipelines.post_merge import (
1011
+ DEFAULT_PR_WATCH_TIMEOUT,
1012
+ pr_watch_task,
1013
+ )
1014
+ from ctrlrelay.pipelines.secops import run_secops_all
1015
+
1016
+ # Build a DashboardClient if configured BEFORE the gh probe runs,
1017
+ # so even if gh fails the user gets clear failure ordering and so
1018
+ # tests can short-circuit at gh while still observing this wiring.
1019
+ # Mirrors what `ctrlrelay run secops` does for the manual path.
1020
+ scheduled_dashboard = None
1021
+ if config.dashboard.enabled and config.dashboard.url:
1022
+ token = os.environ.get(config.dashboard.auth_token_env, "")
1023
+ if token:
1024
+ from ctrlrelay.dashboard.client import DashboardClient
1025
+ scheduled_dashboard = DashboardClient(
1026
+ url=config.dashboard.url,
1027
+ auth_token=token,
1028
+ node_id=config.node_id,
1029
+ queue_dir=config.paths.state_db.parent / "event_queue",
1030
+ )
1031
+
1032
+ github = GitHubCLI()
1033
+
1034
+ # Get GitHub username
1035
+ try:
1036
+ from ctrlrelay.core.github import _find_gh
1037
+ gh_bin = _find_gh()
1038
+ result = subprocess.run(
1039
+ [gh_bin, "api", "user", "--jq", ".login"],
1040
+ capture_output=True,
1041
+ text=True,
1042
+ check=True,
1043
+ # Without this a hung gh hangs `poller start` forever, and
1044
+ # the TimeoutExpired branch of the classifier — the one
1045
+ # case that really does mean connectivity — can never fire.
1046
+ timeout=_GH_PROBE_TIMEOUT_SECONDS,
1047
+ )
1048
+ username = result.stdout.strip()
1049
+ except (subprocess.SubprocessError, OSError) as e:
1050
+ # `gh` exits 1 for offline, expired-token and API-error alike,
1051
+ # so classify from its stderr — otherwise operators debug auth
1052
+ # when the laptop is simply offline (#32).
1053
+ from ctrlrelay.core.network import gh_failure_from_exception
1054
+
1055
+ failure = gh_failure_from_exception(e)
1056
+ color = "yellow" if failure.is_transient else "red"
1057
+ console.print(
1058
+ f"[{color}]Could not determine GitHub username:[/{color}] "
1059
+ f"{failure.message}"
1060
+ )
1061
+ if failure.stderr:
1062
+ # gh's stderr is arbitrary text and Rich parses [...] as
1063
+ # markup: a stderr containing something like "[/docs]"
1064
+ # raises MarkupError, which replaces the diagnostic with a
1065
+ # traceback and skips the exit — losing exactly the detail
1066
+ # this line exists to show.
1067
+ console.print(
1068
+ f"[dim]gh: {rich_escape(failure.stderr)}[/dim]"
1069
+ )
1070
+ raise typer.Exit(1)
1071
+
1072
+ if not username:
1073
+ console.print("[red]Could not determine GitHub username.[/red]")
1074
+ raise typer.Exit(1)
1075
+
1076
+ repo_names = [r.name for r in config.repos]
1077
+ if not repo_names:
1078
+ console.print("[yellow]No repos configured.[/yellow]")
1079
+ return
1080
+
1081
+ accept_foreign = {
1082
+ r.name for r in config.repos if r.automation.accept_foreign_assignments
1083
+ }
1084
+
1085
+ state_file = config.paths.state_db.parent / "poller_state.json"
1086
+
1087
+ first_run = not state_file.exists()
1088
+
1089
+ exclude_labels_by_repo = {
1090
+ r.name: list(r.automation.exclude_labels) for r in config.repos
1091
+ }
1092
+ include_labels_by_repo = {
1093
+ r.name: list(r.automation.include_labels) for r in config.repos
1094
+ }
1095
+ require_labels_by_repo = {
1096
+ r.name: list(r.automation.require_labels) for r in config.repos
1097
+ }
1098
+
1099
+ poller = IssuePoller(
1100
+ github=github,
1101
+ username=username,
1102
+ repos=repo_names,
1103
+ state_file=state_file,
1104
+ accept_foreign_assignments=accept_foreign,
1105
+ exclude_labels_by_repo=exclude_labels_by_repo,
1106
+ include_labels_by_repo=include_labels_by_repo,
1107
+ require_labels_by_repo=require_labels_by_repo,
1108
+ )
1109
+
1110
+ # NOTE: first-run seeding moved into `_main()` so the APScheduler
1111
+ # cron is registered + running BEFORE the slow seed_current() pass
1112
+ # (one GitHub API call per repo) takes place. Otherwise the 6am
1113
+ # scheduled fire can pass during startup and APScheduler's misfire
1114
+ # grace only catches up on fires that happened AFTER registration.
1115
+
1116
+ state_db = StateDB(config.paths.state_db)
1117
+ dispatcher = make_agent_dispatcher(config.agent)
1118
+ worktree = WorktreeManager(
1119
+ worktrees_dir=config.paths.worktrees,
1120
+ bare_repos_dir=config.paths.bare_repos,
1121
+ )
1122
+
1123
+ # Set up transport for notifications
1124
+ transport = None
1125
+ if config.transport.type.value == "telegram" and config.transport.telegram:
1126
+ from ctrlrelay.transports import SocketTransport
1127
+ socket_path = config.transport.telegram.socket_path.expanduser().resolve()
1128
+ if socket_path.exists():
1129
+ transport = SocketTransport(
1130
+ socket_path,
1131
+ ask_timeout_seconds=(
1132
+ config.transport.telegram.ask_timeout_seconds
1133
+ ),
1134
+ )
1135
+ console.print(f"[dim]Telegram transport enabled via {socket_path}[/dim]")
1136
+ else:
1137
+ console.print(f"[yellow]Telegram socket not found at {socket_path}[/yellow]")
1138
+ console.print(
1139
+ "[yellow]Run 'ctrlrelay bridge start' to enable notifications[/yellow]"
1140
+ )
1141
+
1142
+ # Track in-flight PR-watch background tasks so they outlive
1143
+ # handle_issue and aren't garbage-collected. Cleared via
1144
+ # done_callback as each task terminates.
1145
+ pr_watch_tasks: set[asyncio.Task] = set()
1146
+ # Dedup key: only ONE watcher may run for a given (repo, pr_number)
1147
+ # at a time. Without this, two spawn paths (rehydration + a
1148
+ # handle_issue re-run after corrupted poller state) can race
1149
+ # through _handle_merge_with_retry and duplicate the close
1150
+ # comment / notification. Entries removed via done_callback
1151
+ # below, paired with the task's removal from pr_watch_tasks.
1152
+ active_pr_watches: set[tuple[str, int]] = set()
1153
+
1154
+ def _spawn_pr_watch(
1155
+ *,
1156
+ repo: str,
1157
+ issue_number: int,
1158
+ pr_url: str,
1159
+ pr_number: int,
1160
+ session_id: str | None,
1161
+ timeout: int | None = None,
1162
+ skip_initial_persist: bool = False,
1163
+ ) -> asyncio.Task | None:
1164
+ """Start a pr_watch_task for (repo, pr_number) unless one is
1165
+ already running for the same key. Returns the task on spawn,
1166
+ None if a duplicate was suppressed.
1167
+
1168
+ The pr_watches row is persisted SYNCHRONOUSLY before the
1169
+ coroutine is scheduled. asyncio.create_task only queues the
1170
+ coroutine — its body doesn't execute until the loop next
1171
+ yields, so a shutdown between create_task and the first
1172
+ await inside pr_watch_task would otherwise lose the row
1173
+ entirely (no rehydrate after restart). Writing first closes
1174
+ that gap.
1175
+
1176
+ ``skip_initial_persist`` is for rehydration: the row
1177
+ already exists in pr_watches (that's how we know to spawn),
1178
+ and re-writing it would be harmless but wasteful.
1179
+ """
1180
+ key = (repo, pr_number)
1181
+ if key in active_pr_watches:
1182
+ return None
1183
+ if state_db is not None and not skip_initial_persist:
1184
+ try:
1185
+ state_db.add_pr_watch(
1186
+ repo=repo, pr_number=pr_number,
1187
+ issue_number=issue_number,
1188
+ session_id=session_id,
1189
+ pr_url=pr_url,
1190
+ )
1191
+ except Exception:
1192
+ # Persist failure is logged inside pr_watch_task on
1193
+ # its own attempt; don't abort the spawn here, the
1194
+ # watcher can still run in-memory like the legacy
1195
+ # pre-persistence code did.
1196
+ pass
1197
+ active_pr_watches.add(key)
1198
+ kwargs: dict = dict(
1199
+ repo=repo,
1200
+ issue_number=issue_number,
1201
+ pr_url=pr_url,
1202
+ pr_number=pr_number,
1203
+ session_id=session_id,
1204
+ github=github,
1205
+ transport_factory=_watch_transport_factory,
1206
+ state_db=state_db,
1207
+ )
1208
+ if timeout is not None:
1209
+ kwargs["timeout"] = timeout
1210
+ task = asyncio.create_task(pr_watch_task(**kwargs))
1211
+ pr_watch_tasks.add(task)
1212
+
1213
+ def _on_done(t: asyncio.Task) -> None:
1214
+ pr_watch_tasks.discard(t)
1215
+ active_pr_watches.discard(key)
1216
+
1217
+ task.add_done_callback(_on_done)
1218
+ return task
1219
+
1220
+ async def _watch_transport_factory():
1221
+ """Build a fresh connected SocketTransport for a single watcher,
1222
+ independent of the transport used in handle_issue (which is
1223
+ closed on exit).
1224
+
1225
+ Return None ONLY when Telegram notifications aren't
1226
+ configured at all — that's a legitimate "no channel" signal
1227
+ and the retry loop treats it as clean success. A configured-
1228
+ but-currently-missing socket is a transient outage (bridge
1229
+ restart, daemon crash mid-merge), so RAISE to trigger the
1230
+ retry path; the next attempt will reach the socket once the
1231
+ bridge is back.
1232
+ """
1233
+ if config.transport.type.value != "telegram" or not config.transport.telegram:
1234
+ return None
1235
+ from ctrlrelay.transports import SocketTransport
1236
+ socket_path = config.transport.telegram.socket_path.expanduser().resolve()
1237
+ if not socket_path.exists():
1238
+ raise FileNotFoundError(
1239
+ f"Telegram bridge socket missing at {socket_path}; "
1240
+ "retryable — bridge may be restarting"
1241
+ )
1242
+ watch_transport = SocketTransport(
1243
+ socket_path,
1244
+ ask_timeout_seconds=(
1245
+ config.transport.telegram.ask_timeout_seconds
1246
+ ),
1247
+ )
1248
+ await watch_transport.connect()
1249
+ return watch_transport
1250
+
1251
+ async def handle_issue(repo: str, issue: dict) -> None:
1252
+ issue_number = issue["number"]
1253
+ title = issue.get("title", "")
1254
+ console.print(
1255
+ f"[green]New issue detected:[/green] #{issue_number} in {repo} — {title}"
1256
+ )
1257
+
1258
+ # Connect transport for notifications
1259
+ connected_transport = None
1260
+ if transport:
1261
+ try:
1262
+ await transport.connect()
1263
+ connected_transport = transport
1264
+ await transport.send(f"🔔 New issue #{issue_number} in {repo}: {title}")
1265
+ except Exception as e:
1266
+ console.print(f"[yellow]Transport error: {e}[/yellow]")
1267
+
1268
+ # Find matching repo config
1269
+ repo_configs = [r for r in config.repos if r.name == repo]
1270
+ if not repo_configs:
1271
+ console.print(f"[yellow]No config found for repo {repo}, skipping.[/yellow]")
1272
+ if connected_transport:
1273
+ await transport.close()
1274
+ return
1275
+
1276
+ repo_config = repo_configs[0]
1277
+
1278
+ # Route by label: task_labels → task pipeline (report-only,
1279
+ # no PR), otherwise dev pipeline. Case-insensitive match on
1280
+ # the issue's label names. exclude_labels was already
1281
+ # applied in poller.poll() so any issue reaching
1282
+ # handle_issue is a candidate.
1283
+ issue_labels = {
1284
+ (lbl.get("name") or "").lower()
1285
+ for lbl in (issue.get("labels") or [])
1286
+ }
1287
+ task_labels_lower = {
1288
+ lbl.lower() for lbl in repo_config.automation.task_labels
1289
+ }
1290
+ is_task = bool(issue_labels & task_labels_lower)
1291
+
1292
+ try:
1293
+ if is_task:
1294
+ from ctrlrelay.pipelines.task import run_task_issue
1295
+ console.print(
1296
+ f"[dim]#{issue_number} routed to task "
1297
+ f"pipeline (matched labels: "
1298
+ f"{sorted(issue_labels & task_labels_lower)})[/dim]"
1299
+ )
1300
+ result = await run_task_issue(
1301
+ repo=repo,
1302
+ issue_number=issue_number,
1303
+ dispatcher=dispatcher,
1304
+ github=github,
1305
+ worktree=worktree,
1306
+ dashboard=None,
1307
+ state_db=state_db,
1308
+ transport=connected_transport,
1309
+ contexts_dir=config.paths.contexts,
1310
+ )
1311
+ else:
1312
+ result = await run_dev_issue(
1313
+ repo=repo,
1314
+ issue_number=issue_number,
1315
+ branch_template=repo_config.dev_branch_template,
1316
+ dispatcher=dispatcher,
1317
+ github=github,
1318
+ worktree=worktree,
1319
+ dashboard=None,
1320
+ state_db=state_db,
1321
+ transport=connected_transport,
1322
+ contexts_dir=config.paths.contexts,
1323
+ ci_wait_timeout_seconds=repo_config.automation.ci_wait_timeout_seconds,
1324
+ )
1325
+
1326
+ # Lock-conflict retry hook. The poller marks issues seen
1327
+ # BEFORE handle_issue runs, so a failed attempt would
1328
+ # permanently drop the issue. If run_dev_issue couldn't
1329
+ # acquire the per-repo lock up front (common when a
1330
+ # scheduled secops sweep is mid-run on the same repo),
1331
+ # un-mark the issue so the next poll picks it up. Any
1332
+ # other failure still stays seen — those aren't transient.
1333
+ if (
1334
+ not result.success
1335
+ and not result.blocked
1336
+ and result.error
1337
+ and "locked by another session" in result.error.lower()
1338
+ ):
1339
+ poller.unmark_seen(repo, issue_number)
1340
+ console.print(
1341
+ f"[yellow]#{issue_number} in {repo}: repo locked "
1342
+ "(secops running?) — un-marked for retry next "
1343
+ "poll.[/yellow]"
1344
+ )
1345
+ if connected_transport:
1346
+ try:
1347
+ await transport.send(
1348
+ f"⏳ #{issue_number} in {repo} "
1349
+ "deferred (repo busy); will retry."
1350
+ )
1351
+ except Exception:
1352
+ pass
1353
+ return
1354
+
1355
+ # Lock-contended DURING PR verification (issue #29): the
1356
+ # PR already exists AND verification decided it wasn't
1357
+ # ready (_verify_and_fix_pr only returns this error from
1358
+ # the "need to run request_fix but couldn't reacquire"
1359
+ # path). Two sub-cases based on whether run_dev_issue's
1360
+ # cleanup reacquire succeeded:
1361
+ #
1362
+ # (a) Cleanup clean (lock reclaimed, worktree torn down):
1363
+ # un-mark the issue so the next poll retries the fix.
1364
+ # run_dev_issue's branch-reuse path handles
1365
+ # "issue already has a branch / PR" (#51, #52).
1366
+ #
1367
+ # (b) Cleanup deferred (peer still holds lock; worktree
1368
+ # NOT removed): un-marking would make the next poll
1369
+ # call create_worktree_with_new_branch and fail
1370
+ # with "already checked out" because the abandoned
1371
+ # worktree metadata is still registered. Leave the
1372
+ # issue seen and log an operator-actionable warning;
1373
+ # a poller restart (or `git worktree prune`) will
1374
+ # clear the stale entry.
1375
+ #
1376
+ # Either way the merge watcher still spawns below so
1377
+ # the existing PR's auto-close works if a human merges.
1378
+ if (
1379
+ not result.success
1380
+ and not result.blocked
1381
+ and result.error
1382
+ and "reacquire contended during pr verification"
1383
+ in result.error.lower()
1384
+ ):
1385
+ pr_num_str = result.outputs.get("pr_number", "?")
1386
+ cleanup_deferred = bool(
1387
+ result.outputs.get("cleanup_deferred")
1388
+ )
1389
+ if cleanup_deferred:
1390
+ console.print(
1391
+ f"[red]#{issue_number} in {repo}: lock "
1392
+ "contention during PR verification AND "
1393
+ "cleanup could not reclaim the lock after "
1394
+ "the full fix budget (~1 hour). A peer "
1395
+ "session is wedged. Operator action: "
1396
+ "identify the peer holding the lock on "
1397
+ f"repo_locks for {repo} (via `sqlite3 "
1398
+ "state.db 'SELECT * FROM repo_locks'`), "
1399
+ "investigate/kill it, then `git worktree "
1400
+ "remove --force` on the session's "
1401
+ "worktree directory before re-assigning "
1402
+ f"issue #{issue_number}. "
1403
+ f"PR #{pr_num_str} already exists on "
1404
+ "GitHub and is being watched for merge."
1405
+ "[/red]"
1406
+ )
1407
+ else:
1408
+ poller.unmark_seen(repo, issue_number)
1409
+ console.print(
1410
+ f"[yellow]#{issue_number} in {repo}: lock "
1411
+ "contention during PR verification — "
1412
+ f"un-marking for retry (PR #{pr_num_str} "
1413
+ "needs another fix round). Merge watcher "
1414
+ "still spawns.[/yellow]"
1415
+ )
1416
+
1417
+ # Spawn the PR watcher FIRST, before any best-effort
1418
+ # notification. The poller has already marked this issue
1419
+ # as seen in poller_state.json, so if a transient
1420
+ # transport.send failure below raised through the outer
1421
+ # finally, we'd permanently lose the watcher and the
1422
+ # issue would never auto-close on merge.
1423
+ pr_number_raw = result.outputs.get("pr_number")
1424
+ pr_url_str = result.outputs.get("pr_url", "")
1425
+ if pr_number_raw is not None:
1426
+ try:
1427
+ pr_number = int(pr_number_raw)
1428
+ except (TypeError, ValueError):
1429
+ pr_number = None
1430
+ if pr_number is not None:
1431
+ _spawn_pr_watch(
1432
+ repo=repo,
1433
+ issue_number=issue_number,
1434
+ pr_url=pr_url_str,
1435
+ pr_number=pr_number,
1436
+ session_id=result.session_id,
1437
+ )
1438
+
1439
+ # Send result notification — best-effort. A failed send
1440
+ # must NOT prevent the merge watcher (spawned above)
1441
+ # from running, so swallow transport errors here.
1442
+ if connected_transport:
1443
+ try:
1444
+ if result.success:
1445
+ if is_task:
1446
+ await transport.send(
1447
+ f"✅ Task done on #{issue_number} "
1448
+ f"({repo}): {result.summary}"
1449
+ )
1450
+ else:
1451
+ pr_url = result.outputs.get("pr_url", "")
1452
+ await transport.send(
1453
+ f"✅ PR ready: {pr_url}"
1454
+ )
1455
+ elif result.blocked:
1456
+ await transport.send(
1457
+ f"⏸️ Blocked on #{issue_number}: {result.question}"
1458
+ )
1459
+ else:
1460
+ await transport.send(
1461
+ f"❌ Failed on #{issue_number}: "
1462
+ f"{result.error or result.summary}"
1463
+ )
1464
+ except Exception as e:
1465
+ console.print(
1466
+ f"[yellow]Transport error sending result: {e}[/yellow]"
1467
+ )
1468
+ finally:
1469
+ if connected_transport:
1470
+ await transport.close()
1471
+
1472
+ console.print(f"[green]Starting poller[/green] for {len(repo_names)} repo(s) as {username}")
1473
+ console.print(f" Interval: {interval}s | Press Ctrl+C to stop")
1474
+
1475
+ async def _run_scheduled_secops() -> None:
1476
+ """Scheduler callback: run the secops sweep across all repos.
1477
+
1478
+ Shares the poller's open state_db, github, dispatcher, and
1479
+ worktree. Per-repo locks in the state DB prevent collisions
1480
+ with an in-flight dev pipeline (and the dev handler now
1481
+ retries on lock-conflict so issues aren't silently dropped).
1482
+
1483
+ Builds a fresh SocketTransport per run so blocked/failed
1484
+ results notify Telegram — the dashboard only pushes for
1485
+ successful runs, so without a transport here operators would
1486
+ lose visibility into scheduled failures.
1487
+ """
1488
+ if not config.repos:
1489
+ return
1490
+ n_repos = len(config.repos)
1491
+ console.print(
1492
+ f"[dim]Scheduled secops: starting across {n_repos} repo(s)[/dim]"
1493
+ )
1494
+
1495
+ secops_transport = None
1496
+ if config.transport.type.value == "telegram" and config.transport.telegram:
1497
+ from ctrlrelay.transports import SocketTransport
1498
+ sock = config.transport.telegram.socket_path.expanduser().resolve()
1499
+ if sock.exists():
1500
+ try:
1501
+ candidate = SocketTransport(
1502
+ sock,
1503
+ ask_timeout_seconds=(
1504
+ config.transport.telegram
1505
+ .ask_timeout_seconds
1506
+ ),
1507
+ )
1508
+ await candidate.connect()
1509
+ secops_transport = candidate
1510
+ except Exception as e:
1511
+ console.print(
1512
+ f"[yellow]Scheduled secops: transport connect "
1513
+ f"failed ({e}) — running without notifications[/yellow]"
1514
+ )
1515
+
1516
+ # Tell the operator the sweep started so a long run isn't
1517
+ # silent. Without this, a 10-min sweep that ends with a
1518
+ # blocked-on-input result looks like "out of nowhere" pings.
1519
+ if secops_transport:
1520
+ try:
1521
+ await secops_transport.send(
1522
+ f"🔄 Scheduled secops: starting sweep across "
1523
+ f"{n_repos} repo(s)"
1524
+ )
1525
+ except Exception as e:
1526
+ console.print(
1527
+ f"[yellow]Scheduled secops: start-notify failed: "
1528
+ f"{e}[/yellow]"
1529
+ )
1530
+
1531
+ try:
1532
+ results = await run_secops_all(
1533
+ repos=config.repos,
1534
+ dispatcher=dispatcher,
1535
+ github=github,
1536
+ worktree=worktree,
1537
+ dashboard=scheduled_dashboard,
1538
+ state_db=state_db,
1539
+ transport=secops_transport,
1540
+ contexts_dir=config.paths.contexts,
1541
+ )
1542
+ ok = sum(1 for r in results if r.success)
1543
+ console.print(
1544
+ f"[dim]Scheduled secops: {ok}/{len(results)} succeeded[/dim]"
1545
+ )
1546
+
1547
+ # Per-repo notifications for the cases an operator must
1548
+ # act on. The aggregate "N blocked" message we used to
1549
+ # send was useless on its own — it didn't say which
1550
+ # repos blocked or what the question was. Fan out one
1551
+ # message per blocked or failed result with the actual
1552
+ # question/error and session id so the operator can
1553
+ # respond directly via the bridge.
1554
+ #
1555
+ # `run_secops_all` returns results in the same order as
1556
+ # the input `repos` list, so zip is safe — only repos
1557
+ # with successful lock-acquisition produce results.
1558
+ if secops_transport:
1559
+ try:
1560
+ for repo_cfg, result in zip(
1561
+ config.repos, results, strict=False
1562
+ ):
1563
+ if result.blocked:
1564
+ question = (
1565
+ result.question or "(no question text)"
1566
+ )
1567
+ # session_id makes this message
1568
+ # reply-to-able: the bridge records it, so
1569
+ # answering the fan-out resolves the same
1570
+ # pending_resumes row as answering the
1571
+ # original question would have.
1572
+ await secops_transport.send(
1573
+ f"⏸️ Scheduled secops blocked on "
1574
+ f"{repo_cfg.name}\n"
1575
+ f"Session: {result.session_id}\n"
1576
+ f"\nReply to this message to answer."
1577
+ f"\n\n{question}",
1578
+ session_id=result.session_id,
1579
+ repo=repo_cfg.name,
1580
+ )
1581
+ elif not result.success:
1582
+ err = result.error or result.summary
1583
+ await secops_transport.send(
1584
+ f"❌ Scheduled secops failed on "
1585
+ f"{repo_cfg.name}\n"
1586
+ f"Session: `{result.session_id}`\n"
1587
+ f"\n{err}"
1588
+ )
1589
+ # Final at-a-glance summary — kept because it's
1590
+ # the single message the operator scans first.
1591
+ blocked_n = sum(1 for r in results if r.blocked)
1592
+ failed_n = sum(
1593
+ 1 for r in results
1594
+ if not r.success and not r.blocked
1595
+ )
1596
+ if blocked_n or failed_n:
1597
+ parts = []
1598
+ if blocked_n:
1599
+ parts.append(f"{blocked_n} blocked")
1600
+ if failed_n:
1601
+ parts.append(f"{failed_n} failed")
1602
+ await secops_transport.send(
1603
+ f"📋 Scheduled secops sweep done: "
1604
+ f"{ok}/{len(results)} ok, "
1605
+ f"{', '.join(parts)}"
1606
+ )
1607
+ else:
1608
+ await secops_transport.send(
1609
+ f"✅ Scheduled secops sweep done: "
1610
+ f"{ok}/{len(results)} ok"
1611
+ )
1612
+ except Exception as e:
1613
+ console.print(
1614
+ f"[yellow]Scheduled secops: notify failed: {e}[/yellow]"
1615
+ )
1616
+ finally:
1617
+ if secops_transport:
1618
+ try:
1619
+ await secops_transport.close()
1620
+ except Exception:
1621
+ pass
1622
+
1623
+ async def _run_question_expiry_sweeper() -> None:
1624
+ """Retire BLOCKED questions the operator can no longer
1625
+ usefully answer: aged past the TTL, or whose cited PRs and
1626
+ issues have all been closed or merged outside ctrlrelay.
1627
+
1628
+ Hourly, not per-minute: expiry is never urgent, and the
1629
+ resolved-elsewhere pass costs one `gh api` read per cited
1630
+ number. TTL runs first and needs no network, so a GitHub
1631
+ outage still lets the age-based sweep make progress.
1632
+ """
1633
+ ttl = getattr(
1634
+ getattr(config.transport, "telegram", None),
1635
+ "question_ttl_seconds",
1636
+ None,
1637
+ )
1638
+ if not ttl:
1639
+ return
1640
+ try:
1641
+ expired = await expire_stale_questions(
1642
+ state_db, github, ttl_seconds=ttl,
1643
+ )
1644
+ except Exception as e:
1645
+ console.print(
1646
+ f"[yellow]question_expiry_sweeper: failed ({e})[/yellow]"
1647
+ )
1648
+ return
1649
+ for row in expired:
1650
+ console.print(
1651
+ f"[dim]question_expiry: retired "
1652
+ f"{row['session_id']} ({row['expired_reason']})[/dim]"
1653
+ )
1654
+
1655
+ async def _run_pending_resume_sweeper() -> None:
1656
+ """Drain pending_resumes rows that an operator answered via
1657
+ Telegram while the original BLOCKED session had already torn
1658
+ down.
1659
+
1660
+ Runs every minute inside the poller. For each answered row
1661
+ it acquires the repo lock, re-creates a worktree, calls
1662
+ ``SecopsPipeline.resume(ctx, answer)`` via the shared
1663
+ ``resume_secops_from_pending`` helper, then marks the row
1664
+ resumed and fans out a Telegram notification with the result
1665
+ so the operator knows whether the resume landed.
1666
+ """
1667
+ try:
1668
+ pending = state_db.list_pending_resumes_to_execute()
1669
+ except Exception as e:
1670
+ console.print(
1671
+ f"[yellow]pending_resume_sweeper: list failed ({e})"
1672
+ f"[/yellow]"
1673
+ )
1674
+ return
1675
+ if not pending:
1676
+ return
1677
+
1678
+ from ctrlrelay.pipelines.dev import resume_dev_from_pending
1679
+ from ctrlrelay.pipelines.secops import resume_secops_from_pending
1680
+ from ctrlrelay.pipelines.task import resume_task_from_pending
1681
+
1682
+ sweeper_transport = None
1683
+ if config.transport.type.value == "telegram" and config.transport.telegram:
1684
+ from ctrlrelay.transports import SocketTransport
1685
+ sock = config.transport.telegram.socket_path.expanduser().resolve()
1686
+ if sock.exists():
1687
+ try:
1688
+ candidate = SocketTransport(
1689
+ sock,
1690
+ ask_timeout_seconds=(
1691
+ config.transport.telegram
1692
+ .ask_timeout_seconds
1693
+ ),
1694
+ )
1695
+ await candidate.connect()
1696
+ sweeper_transport = candidate
1697
+ except Exception:
1698
+ sweeper_transport = None
1699
+
1700
+ repo_cfg_by_name = {r.name: r for r in config.repos}
1701
+
1702
+ try:
1703
+ for row in pending:
1704
+ session_id = row["session_id"]
1705
+ repo = row["repo"]
1706
+ pipeline_name = row["pipeline"]
1707
+ answer = row["answer"] or ""
1708
+
1709
+ if sweeper_transport:
1710
+ try:
1711
+ await sweeper_transport.send(
1712
+ f"🔁 Resuming BLOCKED session "
1713
+ f"`{session_id}` on {repo} with your "
1714
+ f"answer..."
1715
+ )
1716
+ except Exception:
1717
+ pass
1718
+
1719
+ try:
1720
+ if pipeline_name == "secops":
1721
+ sweep_repo_cfg = repo_cfg_by_name.get(repo)
1722
+ result = await resume_secops_from_pending(
1723
+ session_id=session_id,
1724
+ repo=repo,
1725
+ answer=answer,
1726
+ dispatcher=dispatcher,
1727
+ github=github,
1728
+ worktree=worktree,
1729
+ dashboard=scheduled_dashboard,
1730
+ state_db=state_db,
1731
+ transport=sweeper_transport,
1732
+ contexts_dir=config.paths.contexts,
1733
+ automation=(
1734
+ sweep_repo_cfg.automation
1735
+ if sweep_repo_cfg is not None
1736
+ else None
1737
+ ),
1738
+ question=row.get("question"),
1739
+ )
1740
+ elif pipeline_name == "dev":
1741
+ # Dev resume needs the repo's branch template
1742
+ # to reconstruct the session's existing branch
1743
+ # name (fix/issue-<n> by default). If the
1744
+ # repo fell out of config (rename, removal),
1745
+ # we can't safely resume — mark resumed so
1746
+ # the row doesn't hot-loop and log it.
1747
+ repo_cfg = repo_cfg_by_name.get(repo)
1748
+ if repo_cfg is None:
1749
+ console.print(
1750
+ "[yellow]pending_resume_sweeper: "
1751
+ f"dev resume for {repo} skipped — "
1752
+ "repo not in current config. "
1753
+ f"Session={session_id}[/yellow]"
1754
+ )
1755
+ try:
1756
+ state_db.mark_pending_resume_resumed(
1757
+ session_id
1758
+ )
1759
+ except Exception:
1760
+ pass
1761
+ continue
1762
+ result = await resume_dev_from_pending(
1763
+ session_id=session_id,
1764
+ repo=repo,
1765
+ answer=answer,
1766
+ branch_template=repo_cfg.dev_branch_template,
1767
+ dispatcher=dispatcher,
1768
+ github=github,
1769
+ worktree=worktree,
1770
+ dashboard=scheduled_dashboard,
1771
+ state_db=state_db,
1772
+ transport=sweeper_transport,
1773
+ contexts_dir=config.paths.contexts,
1774
+ )
1775
+ elif pipeline_name == "task":
1776
+ result = await resume_task_from_pending(
1777
+ session_id=session_id,
1778
+ repo=repo,
1779
+ answer=answer,
1780
+ dispatcher=dispatcher,
1781
+ github=github,
1782
+ worktree=worktree,
1783
+ dashboard=scheduled_dashboard,
1784
+ state_db=state_db,
1785
+ transport=sweeper_transport,
1786
+ contexts_dir=config.paths.contexts,
1787
+ )
1788
+ else:
1789
+ console.print(
1790
+ "[yellow]pending_resume_sweeper: "
1791
+ f"unknown pipeline '{pipeline_name}' "
1792
+ f"for session {session_id} — marking "
1793
+ "resumed to avoid hot-loop[/yellow]"
1794
+ )
1795
+ try:
1796
+ state_db.mark_pending_resume_resumed(
1797
+ session_id
1798
+ )
1799
+ except Exception:
1800
+ pass
1801
+ continue
1802
+ except Exception as e:
1803
+ if sweeper_transport:
1804
+ try:
1805
+ await sweeper_transport.send(
1806
+ f"❌ Resume of `{session_id}` on "
1807
+ f"{repo} crashed: {e}"
1808
+ )
1809
+ except Exception:
1810
+ pass
1811
+ # Mark resumed so the sweeper doesn't hot-loop the
1812
+ # same broken row. Operator can inspect via the
1813
+ # sessions table.
1814
+ try:
1815
+ state_db.mark_pending_resume_resumed(session_id)
1816
+ except Exception:
1817
+ pass
1818
+ continue
1819
+
1820
+ # Lock-contention at the INITIAL acquire step is
1821
+ # retryable: the 6am secops cron or an in-flight
1822
+ # dev session holds the repo lock, we never got
1823
+ # to pipeline.resume so the operator's answer is
1824
+ # still intact. Leave the pending_resumes row and
1825
+ # let the next sweeper tick try again.
1826
+ if not result.success and result.error == (
1827
+ "Repository locked by another session"
1828
+ ):
1829
+ console.print(
1830
+ "[dim]pending_resume_sweeper: "
1831
+ f"lock contention on {repo}, will retry "
1832
+ f"next tick (session={session_id})[/dim]"
1833
+ )
1834
+ continue
1835
+
1836
+ # The OTHER lock-contended error fires only AFTER
1837
+ # pipeline.resume consumed the operator's answer
1838
+ # and advanced the agent session to DONE: replaying
1839
+ # the same answer would double-apply side effects
1840
+ # on the PR or fail because the agent session is
1841
+ # already finalized. BUT just marking the row
1842
+ # consumed strands the workflow — the PR needs
1843
+ # another fix round and nothing would trigger it.
1844
+ # Re-insert a fresh pending_resumes row with a
1845
+ # synthetic prompt so a new operator reply can
1846
+ # attach via the bridge and drive the next round.
1847
+ if not result.success and result.error == (
1848
+ "Repo lock reacquire contended during PR verification"
1849
+ ):
1850
+ pr_num = result.outputs.get("pr_number", "?")
1851
+ synthetic_question = (
1852
+ f"Post-verify lock contention on {repo} "
1853
+ f"while resuming session {session_id} "
1854
+ f"(PR #{pr_num}). The prior answer was "
1855
+ "consumed; reply with new guidance to "
1856
+ "retry the fix round once the peer "
1857
+ "session releases."
1858
+ )
1859
+ try:
1860
+ state_db.add_pending_resume(
1861
+ session_id=session_id,
1862
+ pipeline=pipeline_name,
1863
+ repo=repo,
1864
+ question=synthetic_question,
1865
+ )
1866
+ except Exception:
1867
+ pass
1868
+ console.print(
1869
+ f"[yellow]pending_resume_sweeper: "
1870
+ f"verify contention on {repo} (session="
1871
+ f"{session_id}); prior answer consumed, "
1872
+ "fresh pending_resumes row inserted so a "
1873
+ "new operator reply can drive the retry."
1874
+ "[/yellow]"
1875
+ )
1876
+ continue
1877
+
1878
+ try:
1879
+ state_db.mark_pending_resume_resumed(session_id)
1880
+ except Exception:
1881
+ pass
1882
+
1883
+ if sweeper_transport:
1884
+ try:
1885
+ if result.success:
1886
+ await sweeper_transport.send(
1887
+ f"✅ Resume succeeded on {repo}\n"
1888
+ f"Session: `{session_id}`\n"
1889
+ f"\n{result.summary}"
1890
+ )
1891
+ elif result.blocked:
1892
+ q = result.question or "(no question text)"
1893
+ await sweeper_transport.send(
1894
+ f"⏸️ Resume re-blocked on {repo}\n"
1895
+ f"Session: `{session_id}`\n"
1896
+ f"\n{q}"
1897
+ )
1898
+ else:
1899
+ err = result.error or result.summary
1900
+ await sweeper_transport.send(
1901
+ f"❌ Resume failed on {repo}\n"
1902
+ f"Session: `{session_id}`\n"
1903
+ f"\n{err}"
1904
+ )
1905
+ except Exception:
1906
+ pass
1907
+ finally:
1908
+ if sweeper_transport:
1909
+ try:
1910
+ await sweeper_transport.close()
1911
+ except Exception:
1912
+ pass
1913
+
1914
+ async def _main() -> None:
1915
+ async def _run_scheduled_personalization_pull() -> None:
1916
+ """Cron-driven auto-pull of the personalization repo.
1917
+
1918
+ Runs at ``schedules.personalization_cron``. Skips when
1919
+ the working tree is dirty so unsaved operator edits
1920
+ aren't disturbed by the rebase. Conflicts during the
1921
+ rebase abort and are logged; operator must intervene
1922
+ via ``ctrlrelay personalization pull`` to surface the
1923
+ conflict files. Auto-push is intentionally NOT done
1924
+ here — a daemon committing on the operator's behalf
1925
+ without explicit intent is the kind of thing that
1926
+ surprises people; commits stay manual.
1927
+
1928
+ ``auto_pull`` runs synchronous ``git fetch``/``rebase``
1929
+ subprocesses; dispatch to a worker thread so a slow
1930
+ or stuck remote can't stall the poller's event loop
1931
+ (Telegram dispatch, pending-resume sweeper, secops
1932
+ cron). Codex pass 3 caught this.
1933
+ """
1934
+ if config.personalization is None:
1935
+ return
1936
+ import asyncio
1937
+
1938
+ from ctrlrelay.personalization import PersonalizationManager
1939
+ from ctrlrelay.personalization.manager import (
1940
+ PersonalizationError,
1941
+ )
1942
+
1943
+ try:
1944
+ mgr = PersonalizationManager(config)
1945
+ result = await asyncio.to_thread(mgr.auto_pull)
1946
+ except PersonalizationError as e:
1947
+ console.print(
1948
+ f"[yellow]Auto-pull failed: {e}[/yellow]"
1949
+ )
1950
+ return
1951
+ if result.success:
1952
+ console.print(f"[dim]Auto-pull: {result.summary}[/dim]")
1953
+ else:
1954
+ console.print(
1955
+ f"[yellow]Auto-pull: {result.summary}[/yellow]"
1956
+ )
1957
+ if result.conflict_files:
1958
+ for f in result.conflict_files:
1959
+ console.print(f"[yellow] conflict: {f}[/yellow]")
1960
+
1961
+ # Register + start the scheduler FIRST, before any potentially
1962
+ # slow startup work. Otherwise a 6am fire that lands during
1963
+ # seed_current's per-repo GitHub calls would be lost —
1964
+ # APScheduler's misfire_grace_time only rescues fires that
1965
+ # happened AFTER the job was registered.
1966
+ scheduler = make_scheduler(timezone=config.timezone)
1967
+ scheduler.add_cron_job(
1968
+ name="secops",
1969
+ cron_expr=config.schedules.secops_cron,
1970
+ func=_run_scheduled_secops,
1971
+ )
1972
+ # Drain answered pending_resumes every minute so a Telegram
1973
+ # reply to a BLOCKED session turns into an actual pipeline
1974
+ # resume within ~60s, not 24h (the next scheduled secops
1975
+ # cron). Cheap: no-ops when the pending_resumes table is empty.
1976
+ scheduler.add_cron_job(
1977
+ name="pending_resume_sweeper",
1978
+ cron_expr="* * * * *",
1979
+ func=_run_pending_resume_sweeper,
1980
+ )
1981
+ # Retire questions nobody can act on any more. Without
1982
+ # this the pending_resumes table only grows: unanswered
1983
+ # rows stay live targets for orphan reply routing and get
1984
+ # re-posted every sweep, including ones whose PR was
1985
+ # merged by hand days earlier.
1986
+ scheduler.add_cron_job(
1987
+ name="question_expiry_sweeper",
1988
+ cron_expr="0 * * * *",
1989
+ func=_run_question_expiry_sweeper,
1990
+ )
1991
+ # Register the personalization auto-pull only when the
1992
+ # operator has both configured the personalization block
1993
+ # AND set a cron expression. Either alone is intentional
1994
+ # (config without cron = manual sync only; cron without
1995
+ # config = invalid in load_config).
1996
+ personalization_cron_summary = ""
1997
+ if (
1998
+ config.personalization is not None
1999
+ and config.schedules.personalization_cron is not None
2000
+ ):
2001
+ scheduler.add_cron_job(
2002
+ name="personalization_auto_pull",
2003
+ cron_expr=config.schedules.personalization_cron,
2004
+ func=_run_scheduled_personalization_pull,
2005
+ )
2006
+ personalization_cron_summary = (
2007
+ f" | personalization_auto_pull cron="
2008
+ f"{config.schedules.personalization_cron}"
2009
+ )
2010
+ scheduler.start()
2011
+ console.print(
2012
+ f"[dim]Scheduler: secops cron={config.schedules.secops_cron} "
2013
+ f"tz={config.timezone} | "
2014
+ f"pending_resume_sweeper=every 1m | "
2015
+ f"question_expiry_sweeper=hourly"
2016
+ f"{personalization_cron_summary}[/dim]"
2017
+ )
2018
+
2019
+ # Now the slow startup: first-run seeding (one gh call per
2020
+ # repo). Done inside _main so the scheduler is already up.
2021
+ if first_run:
2022
+ console.print(
2023
+ "[dim]First run: seeding with current assignments..."
2024
+ "[/dim]"
2025
+ )
2026
+ await poller.seed_current()
2027
+
2028
+ # Rehydrate PR watchers that survived the previous shutdown.
2029
+ # A launchd kickstart / crash / redeploy cancels in-memory
2030
+ # asyncio tasks; without this step, any PR sitting in review
2031
+ # across a restart would silently lose its post-merge
2032
+ # automation for the remainder of its 7-day watch window.
2033
+ # Runs BEFORE run_poll_loop so watchers are live before the
2034
+ # first poll interval elapses.
2035
+ try:
2036
+ surviving = state_db.list_pr_watches()
2037
+ except Exception as e:
2038
+ console.print(
2039
+ f"[yellow]pr_watches rehydrate: list failed ({e}) — "
2040
+ "skipping[/yellow]"
2041
+ )
2042
+ surviving = []
2043
+ if surviving:
2044
+ import time as _time
2045
+ # Respect the operator's current config: if a repo was
2046
+ # removed from `repos:`, skip respawning its watchers
2047
+ # even if old rows linger in pr_watches. Treating the
2048
+ # config as the source of truth matches how the rest
2049
+ # of the poller behaves; silently driving close/notify
2050
+ # on a now-unconfigured repo would be surprising.
2051
+ configured_repos = {r.name for r in config.repos}
2052
+ orphaned = [
2053
+ r for r in surviving if r["repo"] not in configured_repos
2054
+ ]
2055
+ if orphaned:
2056
+ console.print(
2057
+ f"[yellow]pr_watches: {len(orphaned)} row(s) "
2058
+ "for repos no longer in config — leaving "
2059
+ "dormant[/yellow]"
2060
+ )
2061
+ active = [
2062
+ r for r in surviving if r["repo"] in configured_repos
2063
+ ]
2064
+ console.print(
2065
+ f"[dim]Rehydrating {len(active)} PR watcher(s) "
2066
+ "from state.db[/dim]"
2067
+ )
2068
+ now = int(_time.time())
2069
+ # Always spawn with enough headroom for SEVERAL poll
2070
+ # attempts even on rows past the 7-day window. A
2071
+ # single-poll window (grace == poll_interval) would
2072
+ # expire the instant the first gh call has a transient
2073
+ # failure, logging watch_timeout and permanently
2074
+ # dropping the row. 5 minutes against the default 60s
2075
+ # poll_interval lets wait_for_merge retry through a
2076
+ # handful of hiccups and still covers a PR that merges
2077
+ # right after the restart. Abandoned-row cost is 5 min
2078
+ # of extra polling, which is negligible.
2079
+ # (Rehydrated rows with cleanup_phase set bypass
2080
+ # wait_for_merge entirely — see pr_watch_task — so
2081
+ # this headroom only affects phase=NULL rehydrates.)
2082
+ rehydrate_min_timeout = 300
2083
+ for row in active:
2084
+ # Honor the original watch deadline: subtract time
2085
+ # already spent watching from the default 7-day
2086
+ # timeout. Without this, every restart resets the
2087
+ # clock and abandoned PRs never time out.
2088
+ elapsed = max(0, now - int(row["started_at"]))
2089
+ remaining = max(
2090
+ DEFAULT_PR_WATCH_TIMEOUT - elapsed,
2091
+ rehydrate_min_timeout,
2092
+ )
2093
+ _spawn_pr_watch(
2094
+ repo=row["repo"],
2095
+ issue_number=row["issue_number"],
2096
+ pr_url=row["pr_url"] or "",
2097
+ pr_number=row["pr_number"],
2098
+ session_id=row.get("session_id"),
2099
+ timeout=remaining,
2100
+ skip_initial_persist=True,
2101
+ )
2102
+
2103
+ try:
2104
+ await run_poll_loop(
2105
+ poller=poller, handler=handle_issue, interval=interval,
2106
+ )
2107
+ finally:
2108
+ # Scheduler shutdown is async so it can cancel + await any
2109
+ # in-flight job tasks (e.g. a scheduled secops sweep that was
2110
+ # running when SIGTERM arrived). Without awaiting here the
2111
+ # loop closes before jobs finalize — state_db locks stay
2112
+ # held and worktrees stay dirty.
2113
+ await scheduler.shutdown()
2114
+ # Cancel any in-flight PR watchers so a poller stop/restart
2115
+ # doesn't leak asyncio tasks. Their handlers log
2116
+ # dev.pr.watch_cancelled and close their transport via the
2117
+ # finally block.
2118
+ if pr_watch_tasks:
2119
+ for t in list(pr_watch_tasks):
2120
+ t.cancel()
2121
+ await asyncio.gather(*list(pr_watch_tasks), return_exceptions=True)
2122
+
2123
+ loop = asyncio.new_event_loop()
2124
+ try:
2125
+ asyncio.set_event_loop(loop)
2126
+ main_task = loop.create_task(_main())
2127
+
2128
+ def _handle_stop(sig: int) -> None:
2129
+ main_task.cancel()
2130
+
2131
+ for sig in (signal.SIGTERM, signal.SIGINT):
2132
+ loop.add_signal_handler(sig, _handle_stop, sig)
2133
+
2134
+ try:
2135
+ loop.run_until_complete(main_task)
2136
+ except asyncio.CancelledError:
2137
+ console.print("\n[yellow]Poller stopped.[/yellow]")
2138
+ finally:
2139
+ loop.close()
2140
+ state_db.close()
2141
+ finally:
2142
+ pid_file.unlink(missing_ok=True)
2143
+
2144
+
2145
+ @poller_app.command("stop")
2146
+ def poller_stop(
2147
+ config_path: str | None = typer.Option(
2148
+ None,
2149
+ "--config",
2150
+ "-c",
2151
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
2152
+ ),
2153
+ ) -> None:
2154
+ """Stop the issue poller."""
2155
+ import os
2156
+ import signal
2157
+
2158
+ pid_file = _get_poller_pid_file(config_path)
2159
+
2160
+ if not pid_file.exists():
2161
+ console.print("[yellow]Poller not running (no PID file)[/yellow]")
2162
+ return
2163
+
2164
+ try:
2165
+ pid = int(pid_file.read_text().strip())
2166
+ os.kill(pid, signal.SIGTERM)
2167
+ console.print(f"[green]Stopped poller (PID {pid})[/green]")
2168
+ pid_file.unlink(missing_ok=True)
2169
+ except ProcessLookupError:
2170
+ console.print("[yellow]Poller process not found[/yellow]")
2171
+ pid_file.unlink(missing_ok=True)
2172
+ except ValueError:
2173
+ console.print("[red]Invalid PID file[/red]")
2174
+ pid_file.unlink(missing_ok=True)
2175
+
2176
+
2177
+ @poller_app.command("status")
2178
+ def poller_status(
2179
+ config_path: str | None = typer.Option(
2180
+ None,
2181
+ "--config",
2182
+ "-c",
2183
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
2184
+ ),
2185
+ ) -> None:
2186
+ """Check poller status."""
2187
+ import os
2188
+
2189
+ pid_file = _get_poller_pid_file(config_path)
2190
+
2191
+ if pid_file.exists():
2192
+ try:
2193
+ pid = int(pid_file.read_text().strip())
2194
+ os.kill(pid, 0)
2195
+ console.print(f"[green]Poller running (PID {pid})[/green]")
2196
+ return
2197
+ except (ProcessLookupError, ValueError):
2198
+ pass
2199
+
2200
+ console.print("[dim]Poller not running[/dim]")
2201
+ raise typer.Exit(1)
2202
+
2203
+
2204
+ personalization_app = typer.Typer(help="Personalization sync (cross-machine operator state).")
2205
+ app.add_typer(personalization_app, name="personalization")
2206
+
2207
+
2208
+ def _load_config_or_exit(config_path: str | None):
2209
+ """Resolve --config and load it, mapping ConfigError to a typer.Exit."""
2210
+ path = _resolve_config_or_exit(config_path)
2211
+ try:
2212
+ return load_config(path)
2213
+ except ConfigError as e:
2214
+ console.print(f"[red]Error loading config:[/red] {e}")
2215
+ raise typer.Exit(1)
2216
+
2217
+
2218
+ def _make_personalization_manager(config_path: str | None):
2219
+ """Return a configured PersonalizationManager or exit with a helpful error."""
2220
+ from ctrlrelay.personalization import PersonalizationManager
2221
+ from ctrlrelay.personalization.manager import PersonalizationError
2222
+
2223
+ config = _load_config_or_exit(config_path)
2224
+ if config.personalization is None:
2225
+ console.print(
2226
+ "[red]Error:[/red] No personalization config in orchestrator.yaml. "
2227
+ "Add a `personalization:` section (see config/orchestrator.yaml.example) "
2228
+ "and re-run."
2229
+ )
2230
+ raise typer.Exit(1)
2231
+ try:
2232
+ return PersonalizationManager(config)
2233
+ except PersonalizationError as e:
2234
+ console.print(f"[red]Error:[/red] {e}")
2235
+ raise typer.Exit(1)
2236
+
2237
+
2238
+ @personalization_app.command("init")
2239
+ def personalization_init(
2240
+ config_path: str | None = typer.Option(
2241
+ None,
2242
+ "--config",
2243
+ "-c",
2244
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
2245
+ ),
2246
+ no_adopt: bool = typer.Option(
2247
+ False,
2248
+ "--no-adopt",
2249
+ help=(
2250
+ "Refuse to adopt pre-existing real files at target paths. "
2251
+ "Default: adopt (move target into checkout, then symlink). "
2252
+ "Use --no-adopt for the conservative Slice 1 behavior of "
2253
+ "skipping such targets with a back-up-and-remove instruction."
2254
+ ),
2255
+ ),
2256
+ ) -> None:
2257
+ """Clone the personalization repo and wire symlinks.
2258
+
2259
+ Idempotent: re-running on a machine that already cloned the repo
2260
+ converges branch + symlinks rather than failing.
2261
+ """
2262
+ from ctrlrelay.personalization.manager import PersonalizationError
2263
+
2264
+ mgr = _make_personalization_manager(config_path)
2265
+ try:
2266
+ summary = mgr.init(adopt=not no_adopt)
2267
+ except PersonalizationError as e:
2268
+ console.print(f"[red]Init failed:[/red] {e}")
2269
+ raise typer.Exit(1)
2270
+ console.print(summary)
2271
+
2272
+
2273
+ @personalization_app.command("status")
2274
+ def personalization_status(
2275
+ config_path: str | None = typer.Option(
2276
+ None,
2277
+ "--config",
2278
+ "-c",
2279
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
2280
+ ),
2281
+ ) -> None:
2282
+ """Show working-tree state and per-symlink correctness. Read-only."""
2283
+ mgr = _make_personalization_manager(config_path)
2284
+ console.print(mgr.status())
2285
+
2286
+
2287
+ @personalization_app.command("push")
2288
+ def personalization_push(
2289
+ config_path: str | None = typer.Option(
2290
+ None,
2291
+ "--config",
2292
+ "-c",
2293
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
2294
+ ),
2295
+ message: str | None = typer.Option(
2296
+ None,
2297
+ "--message",
2298
+ "-m",
2299
+ help="Commit message override (default: auto-generated from branch name).",
2300
+ ),
2301
+ ) -> None:
2302
+ """Commit, rebase onto main, and push. Aborts the rebase on conflict.
2303
+
2304
+ On conflict the working tree is restored to its pre-rebase state and
2305
+ the conflicting files are listed; resolve them in the checkout (the
2306
+ target of any symlinked path), commit manually, and re-run push.
2307
+ """
2308
+ from ctrlrelay.personalization.manager import PersonalizationError
2309
+
2310
+ mgr = _make_personalization_manager(config_path)
2311
+ try:
2312
+ result = mgr.push(message=message)
2313
+ except PersonalizationError as e:
2314
+ console.print(f"[red]Push failed:[/red] {e}")
2315
+ raise typer.Exit(1)
2316
+ console.print(result.summary)
2317
+ if result.conflict_files:
2318
+ console.print("[yellow]conflicts:[/yellow]")
2319
+ for f in result.conflict_files:
2320
+ console.print(f" {f}")
2321
+ if not result.success:
2322
+ raise typer.Exit(1)
2323
+
2324
+
2325
+ @personalization_app.command("pull")
2326
+ def personalization_pull(
2327
+ config_path: str | None = typer.Option(
2328
+ None,
2329
+ "--config",
2330
+ "-c",
2331
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
2332
+ ),
2333
+ ) -> None:
2334
+ """Fetch + rebase the local working branch and re-wire symlinks."""
2335
+ from ctrlrelay.personalization.manager import PersonalizationError
2336
+
2337
+ mgr = _make_personalization_manager(config_path)
2338
+ try:
2339
+ result = mgr.pull()
2340
+ except PersonalizationError as e:
2341
+ console.print(f"[red]Pull failed:[/red] {e}")
2342
+ raise typer.Exit(1)
2343
+ console.print(result.summary)
2344
+ if result.conflict_files:
2345
+ console.print("[yellow]conflicts:[/yellow]")
2346
+ for f in result.conflict_files:
2347
+ console.print(f" {f}")
2348
+ if not result.success:
2349
+ raise typer.Exit(1)
2350
+
2351
+
2352
+ @app.command("setup")
2353
+ def setup(
2354
+ owner: list[str] = typer.Option(
2355
+ [],
2356
+ "--owner",
2357
+ help=(
2358
+ "GitHub owner to include (repeat for multiple). When omitted, "
2359
+ "the command prompts to pick from your accessible owners."
2360
+ ),
2361
+ ),
2362
+ repo_root: Path = typer.Option(
2363
+ Path("~/Projects").expanduser(),
2364
+ "--repo-root",
2365
+ help="Folder under which repos will be cloned (one subdir per owner).",
2366
+ ),
2367
+ config_out: Path = typer.Option(
2368
+ Path("~/.config/ctrlrelay/orchestrator.yaml").expanduser(),
2369
+ "--config-out",
2370
+ help="Where to write the generated orchestrator.yaml.",
2371
+ ),
2372
+ timezone: str = typer.Option(
2373
+ "UTC",
2374
+ "--timezone",
2375
+ help="IANA timezone for scheduled jobs (e.g. 'America/Santiago').",
2376
+ ),
2377
+ transport: str = typer.Option(
2378
+ "file_mock",
2379
+ "--transport",
2380
+ help="Transport for operator notifications: 'file_mock' or 'telegram'.",
2381
+ ),
2382
+ telegram_chat_id: int | None = typer.Option(
2383
+ None,
2384
+ "--telegram-chat-id",
2385
+ help="Telegram chat ID. Only used when --transport=telegram.",
2386
+ ),
2387
+ personalization_repo: str | None = typer.Option(
2388
+ None,
2389
+ "--personalization-repo",
2390
+ help=(
2391
+ "owner/repo for cross-machine sync (e.g. 'alice/dotclaude'). "
2392
+ "Adds a personalization block to the config."
2393
+ ),
2394
+ ),
2395
+ no_personalization: bool = typer.Option(
2396
+ False,
2397
+ "--no-personalization",
2398
+ help="Skip the personalization prompt entirely (non-interactive mode).",
2399
+ ),
2400
+ wire_skills: bool = typer.Option(
2401
+ True,
2402
+ "--wire-skills/--no-wire-skills",
2403
+ help=(
2404
+ "When the personalization repo already contains "
2405
+ "global/skills/<name>/ directories, add a paths: entry per "
2406
+ "skill so they sync to ~/.claude/skills/<name>/. Default: on."
2407
+ ),
2408
+ ),
2409
+ install_daemons: bool = typer.Option(
2410
+ False,
2411
+ "--install-daemons",
2412
+ help=(
2413
+ "Render and write launchd (macOS) / systemd (Linux) unit files. "
2414
+ "Requires --transport=telegram + token in $CTRLRELAY_TELEGRAM_TOKEN "
2415
+ "for the rendered plist to be usable."
2416
+ ),
2417
+ ),
2418
+ skip_archived: bool = typer.Option(
2419
+ True, "--skip-archived/--include-archived"
2420
+ ),
2421
+ skip_forks: bool = typer.Option(True, "--skip-forks/--include-forks"),
2422
+ yes: bool = typer.Option(
2423
+ False,
2424
+ "--yes",
2425
+ "-y",
2426
+ help="Accept all interactive defaults; never prompt.",
2427
+ ),
2428
+ force: bool = typer.Option(
2429
+ False,
2430
+ "--force",
2431
+ help=(
2432
+ "Overwrite an existing orchestrator.yaml (and existing daemon "
2433
+ "plists when --install-daemons)."
2434
+ ),
2435
+ ),
2436
+ ) -> None:
2437
+ """First-run setup: detect orgs, enumerate repos, write config, clone, optional daemon install.
2438
+
2439
+ The interactive flow asks one question at a time and accepts every
2440
+ answer with a sensible default. Pass --yes to skip prompts and
2441
+ take all defaults. Provide --owner repeatedly to lock the owner
2442
+ list non-interactively.
2443
+ """
2444
+ from ctrlrelay.setup import (
2445
+ GhAuthError,
2446
+ SetupOptions,
2447
+ assert_gh_auth,
2448
+ detect_owners,
2449
+ run_setup,
2450
+ )
2451
+
2452
+ try:
2453
+ assert_gh_auth()
2454
+ except GhAuthError as e:
2455
+ console.print(f"[red]Setup blocked:[/red] {e}")
2456
+ raise typer.Exit(1)
2457
+
2458
+ # ----- owners --------------------------------------------------------
2459
+ chosen_owners: list[str] = list(owner)
2460
+ if not chosen_owners:
2461
+ try:
2462
+ available = detect_owners()
2463
+ except RuntimeError as e:
2464
+ console.print(f"[red]Setup blocked:[/red] could not list owners: {e}")
2465
+ raise typer.Exit(1)
2466
+ if yes:
2467
+ chosen_owners = available # take everything
2468
+ else:
2469
+ console.print(
2470
+ "Available owners on this machine's gh login:\n "
2471
+ + "\n ".join(f"{i + 1}. {o}" for i, o in enumerate(available))
2472
+ )
2473
+ picked_raw = typer.prompt(
2474
+ "Which to monitor? Enter numbers separated by space "
2475
+ "(or 'all' for everything)",
2476
+ default="all",
2477
+ ).strip()
2478
+ if picked_raw.lower() == "all":
2479
+ chosen_owners = available
2480
+ else:
2481
+ try:
2482
+ indexes = [int(x) - 1 for x in picked_raw.split()]
2483
+ chosen_owners = [available[i] for i in indexes]
2484
+ except (ValueError, IndexError):
2485
+ console.print(f"[red]Invalid selection:[/red] {picked_raw}")
2486
+ raise typer.Exit(2)
2487
+ if not chosen_owners:
2488
+ console.print("[red]Setup blocked:[/red] no owners chosen")
2489
+ raise typer.Exit(2)
2490
+
2491
+ # ----- personalization ----------------------------------------------
2492
+ chosen_personalization = personalization_repo
2493
+ if (
2494
+ chosen_personalization is None
2495
+ and not no_personalization
2496
+ and not yes
2497
+ ):
2498
+ if typer.confirm(
2499
+ "Configure cross-machine personalization sync (CLAUDE.md, skills, memory)?",
2500
+ default=False,
2501
+ ):
2502
+ chosen_personalization = typer.prompt(
2503
+ "Personalization repo (owner/repo)"
2504
+ ).strip() or None
2505
+
2506
+ # ----- telegram chat_id (required for telegram transport) ------------
2507
+ # ``chat_id: 0`` is a footgun (the bridge would happily try to send
2508
+ # there), so we force the operator to supply a real value before
2509
+ # writing the config — interactively or via --telegram-chat-id.
2510
+ if transport == "telegram":
2511
+ if telegram_chat_id is None:
2512
+ if yes:
2513
+ console.print(
2514
+ "[red]Setup blocked:[/red] --transport telegram "
2515
+ "requires --telegram-chat-id (non-interactive mode)."
2516
+ )
2517
+ raise typer.Exit(2)
2518
+ chat_raw = typer.prompt("Telegram chat ID").strip()
2519
+ try:
2520
+ telegram_chat_id = int(chat_raw)
2521
+ except ValueError:
2522
+ console.print(f"[red]Invalid chat ID:[/red] {chat_raw!r}")
2523
+ raise typer.Exit(2)
2524
+ if telegram_chat_id == 0:
2525
+ console.print(
2526
+ "[red]Setup blocked:[/red] telegram chat_id must be non-zero "
2527
+ "for the bridge to deliver messages."
2528
+ )
2529
+ raise typer.Exit(2)
2530
+
2531
+ # ----- daemons -------------------------------------------------------
2532
+ chosen_install_daemons = install_daemons
2533
+ if not chosen_install_daemons and not yes:
2534
+ chosen_install_daemons = typer.confirm(
2535
+ "Render launchd/systemd unit files for the bridge and poller?",
2536
+ default=transport == "telegram",
2537
+ )
2538
+
2539
+ # ----- telegram token (only relevant for plist rendering) ------------
2540
+ # The token is only baked into a rendered plist *if* we're also
2541
+ # installing daemons in this same setup run. Missing token is NOT
2542
+ # a reason to flip the transport: the config only stores
2543
+ # ``bot_token_env``; the literal value can be exported later before
2544
+ # bootstrapping the bridge.
2545
+ token: str | None = None
2546
+ if transport == "telegram" and chosen_install_daemons:
2547
+ token = os.environ.get("CTRLRELAY_TELEGRAM_TOKEN")
2548
+ if token is None and not yes:
2549
+ token = typer.prompt(
2550
+ "Telegram bot token (input hidden, used only to render plists)",
2551
+ hide_input=True,
2552
+ ).strip() or None
2553
+ if token is None:
2554
+ console.print(
2555
+ "[yellow]Warning:[/yellow] no Telegram token in env or prompt. "
2556
+ "Rendered plist will carry the ${CTRLRELAY_TELEGRAM_TOKEN} "
2557
+ "placeholder; export the token before bootstrapping the bridge."
2558
+ )
2559
+
2560
+ options = SetupOptions(
2561
+ owners=chosen_owners,
2562
+ repo_root=repo_root.expanduser(),
2563
+ config_out=config_out.expanduser(),
2564
+ timezone=timezone,
2565
+ transport=transport,
2566
+ telegram_chat_id=telegram_chat_id,
2567
+ telegram_token=token,
2568
+ personalization_repo=chosen_personalization,
2569
+ wire_skills=wire_skills,
2570
+ install_daemons=chosen_install_daemons,
2571
+ skip_archived=skip_archived,
2572
+ skip_forks=skip_forks,
2573
+ force=force,
2574
+ )
2575
+
2576
+ console.print(
2577
+ f"\n[bold]Running setup[/bold]\n"
2578
+ f" owners: {', '.join(options.owners)}\n"
2579
+ f" repo_root: {options.repo_root}\n"
2580
+ f" config_out: {options.config_out}\n"
2581
+ f" transport: {options.transport}\n"
2582
+ f" personalization: {options.personalization_repo or '(skipped)'}\n"
2583
+ f" install_daemons: {options.install_daemons}\n"
2584
+ )
2585
+ try:
2586
+ result = run_setup(options)
2587
+ except FileExistsError as e:
2588
+ console.print(f"[red]{e}[/red]")
2589
+ raise typer.Exit(1)
2590
+ except (ValueError, RuntimeError) as e:
2591
+ console.print(f"[red]Setup failed:[/red] {e}")
2592
+ raise typer.Exit(1)
2593
+
2594
+ console.print(
2595
+ f"\n[green]✓ Wrote {result.config_out} ({result.n_repos} repos across "
2596
+ f"{len(result.owners)} owners)[/green]"
2597
+ )
2598
+ console.print(
2599
+ f" clone results: [green]{result.cloned} cloned[/green], "
2600
+ f"[dim]{result.skipped} skipped[/dim], "
2601
+ f"[red]{result.failed} failed[/red]"
2602
+ )
2603
+ if result.personalization_summary:
2604
+ console.print(f" personalization: {result.personalization_summary}")
2605
+ if result.personalization_failed:
2606
+ console.print(
2607
+ "\n[bold red]ACTION NEEDED[/bold red] — personalization repo "
2608
+ "sync failed; the rest of setup completed but your "
2609
+ "personalization config/skills were not cloned. Check your "
2610
+ "git auth (`gh auth status`, `gh config get git_protocol`) "
2611
+ "and re-run `ctrlrelay personalization init` once it's fixed."
2612
+ )
2613
+ if result.daemon_units:
2614
+ console.print(" daemon unit files:")
2615
+ for p in result.daemon_units:
2616
+ console.print(f" - {p}")
2617
+ console.print(
2618
+ "\n[bold]Next:[/bold] bootstrap the daemons via:\n"
2619
+ " launchctl bootstrap gui/$(id -u) <plist>"
2620
+ " # macOS, one per file above\n"
2621
+ " systemctl --user daemon-reload && systemctl --user enable --now <unit>"
2622
+ " # Linux"
2623
+ )
2624
+ # Clone and personalization-sync failures must surface as a
2625
+ # non-zero exit even when daemon units were rendered — automation
2626
+ # watching exit code shouldn't mistake a partial setup for success.
2627
+ if result.failed or result.personalization_failed:
2628
+ raise typer.Exit(1)
2629
+
2630
+
2631
+ @app.command("version")
2632
+ def version() -> None:
2633
+ """Print the package version."""
2634
+ console.print(__version__)
2635
+
2636
+
2637
+ @app.command("status")
2638
+ def status(
2639
+ config_path: str | None = typer.Option(
2640
+ None,
2641
+ "--config",
2642
+ "-c",
2643
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
2644
+ ),
2645
+ ) -> None:
2646
+ """Show orchestrator status and active sessions."""
2647
+ from ctrlrelay.core.state import StateDB
2648
+
2649
+ try:
2650
+ config = load_config(resolve_config_path(config_path))
2651
+ except ConfigError as e:
2652
+ console.print(f"[red]Error loading config:[/red] {e}")
2653
+ raise typer.Exit(1)
2654
+
2655
+ db_path = config.paths.state_db
2656
+ if not db_path.exists():
2657
+ console.print(f"[yellow]State database not found at {db_path}[/yellow]")
2658
+ console.print("Run a pipeline first to initialize the database.")
2659
+ return
2660
+
2661
+ import sqlite3
2662
+
2663
+ try:
2664
+ db = StateDB(db_path)
2665
+ except sqlite3.Error as e:
2666
+ console.print(f"[red]Error opening database:[/red] {e}")
2667
+ raise typer.Exit(1)
2668
+
2669
+ try:
2670
+ # Show locks
2671
+ locks = db.list_locks()
2672
+ if locks:
2673
+ console.print("\n[bold]Active Locks:[/bold]")
2674
+ for lock in locks:
2675
+ console.print(f" • {lock['repo']} → session {lock['session_id']}")
2676
+ else:
2677
+ console.print("\n[dim]No active locks[/dim]")
2678
+
2679
+ # Show recent sessions
2680
+ rows = db.execute(
2681
+ "SELECT * FROM sessions ORDER BY started_at DESC LIMIT 5"
2682
+ ).fetchall()
2683
+
2684
+ if rows:
2685
+ console.print("\n[bold]Recent Sessions:[/bold]")
2686
+ table = Table()
2687
+ table.add_column("ID", style="dim", max_width=12)
2688
+ table.add_column("Pipeline")
2689
+ table.add_column("Repo")
2690
+ table.add_column("Status")
2691
+
2692
+ for row in rows:
2693
+ status_style = {
2694
+ "done": "green",
2695
+ "failed": "red",
2696
+ "running": "yellow",
2697
+ "blocked": "blue",
2698
+ }.get(row["status"], "white")
2699
+
2700
+ table.add_row(
2701
+ row["id"][:12],
2702
+ row["pipeline"],
2703
+ row["repo"],
2704
+ f"[{status_style}]{row['status']}[/{status_style}]",
2705
+ )
2706
+ console.print(table)
2707
+ else:
2708
+ console.print("\n[dim]No sessions recorded yet[/dim]")
2709
+ except sqlite3.Error as e:
2710
+ console.print(f"[red]Database error:[/red] {e}")
2711
+ raise typer.Exit(1)
2712
+ finally:
2713
+ db.close()
2714
+
2715
+
2716
+ repos_app = typer.Typer(help="Bulk repo operations across the orchestrator manifest.")
2717
+ app.add_typer(repos_app, name="repos")
2718
+
2719
+
2720
+ def _iter_repos(config_path: str | None, filter_str: str | None):
2721
+ """Yield (name, org, repo, remote, local_path) tuples for repos in
2722
+ the orchestrator config.
2723
+
2724
+ ``local_path`` is the fully-resolved path the dev pipeline expects,
2725
+ so callers (clone-all, pull-all, repos status) land repos in the
2726
+ same place every other ctrlrelay command operates on. Before 0.4.0
2727
+ these handlers ignored ``local_path`` and computed ``DEST/org/repo``
2728
+ from the raw owner — which broke when ``paths.owner_aliases`` was
2729
+ in play. The resolved value comes from the config validator so
2730
+ legacy aliases, lowercase-owner derivation, and per-repo overrides
2731
+ all collapse into one source of truth here.
2732
+ """
2733
+ try:
2734
+ config = load_config(resolve_config_path(config_path))
2735
+ except ConfigError as e:
2736
+ console.print(f"[red]Error loading config:[/red] {e}")
2737
+ raise typer.Exit(1)
2738
+
2739
+ needle = filter_str.lower() if filter_str else None
2740
+ for r in config.repos:
2741
+ if needle and needle not in r.name.lower():
2742
+ continue
2743
+ if "/" not in r.name:
2744
+ console.print(
2745
+ f"[yellow]skip[/yellow] {r.name} — name must be 'org/repo'"
2746
+ )
2747
+ continue
2748
+ org, repo = r.name.split("/", 1)
2749
+ remote = f"git@github.com:{r.name}.git"
2750
+ # ``local_path`` is always populated by the time we reach here:
2751
+ # either the operator declared it explicitly OR the model
2752
+ # validator filled it in from ``paths.repo_root + owner.lower()``.
2753
+ # If neither path was taken the validator would have raised at
2754
+ # config load, never letting us get this far.
2755
+ local_path = Path(str(r.local_path)).expanduser()
2756
+ yield r.name, org, repo, remote, local_path
2757
+
2758
+
2759
+ def _resolve_target(
2760
+ dest: Path | None,
2761
+ org: str,
2762
+ repo: str,
2763
+ config_local_path: Path,
2764
+ ) -> Path:
2765
+ """Return where a repo should land on disk.
2766
+
2767
+ Without ``dest``, use the config-resolved ``local_path`` so
2768
+ clone-all/pull-all/status agree with the dev pipeline. With
2769
+ ``dest``, override to ``dest/<owner.lower()>/<repo>`` (the new
2770
+ convention since 0.4.0). Pre-0.4.0 the dest-provided form used
2771
+ the raw owner with mixed casing — that was the source of the
2772
+ clone-all/local_path mismatch bug. See #128.
2773
+ """
2774
+ if dest is None:
2775
+ return config_local_path
2776
+ return Path(dest).expanduser().resolve() / org.lower() / repo
2777
+
2778
+
2779
+ @repos_app.command("clone-all")
2780
+ def repos_clone_all(
2781
+ dest: Path | None = typer.Argument(
2782
+ None,
2783
+ help=(
2784
+ "Workspace root override (e.g. ~/code/sandbox). When omitted, "
2785
+ "each repo lands at its resolved local_path from the config."
2786
+ ),
2787
+ ),
2788
+ config_path: str | None = typer.Option(
2789
+ None,
2790
+ "--config",
2791
+ "-c",
2792
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
2793
+ ),
2794
+ filter_str: str | None = typer.Option(
2795
+ None, "--filter", "-f", help="Substring filter on repo name (e.g. 'AInvirion')"
2796
+ ),
2797
+ dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show actions without running"),
2798
+ ) -> None:
2799
+ """Clone every configured repo to its resolved local_path
2800
+ (or DEST/<owner.lower()>/<repo> when DEST is given)."""
2801
+ import subprocess
2802
+
2803
+ cloned = skipped = failed = 0
2804
+
2805
+ for name, org, repo, remote, local_path in _iter_repos(config_path, filter_str):
2806
+ target = _resolve_target(dest, org, repo, local_path)
2807
+ if (target / ".git").exists():
2808
+ console.print(f"[dim]✓ {name} (already cloned)[/dim]")
2809
+ skipped += 1
2810
+ continue
2811
+ if target.exists() and any(target.iterdir()):
2812
+ console.print(f"[yellow]skip[/yellow] {name} — {target} exists and is not empty")
2813
+ skipped += 1
2814
+ continue
2815
+ if dry_run:
2816
+ console.print(f"[blue]would clone[/blue] {remote} → {target}")
2817
+ continue
2818
+ target.parent.mkdir(parents=True, exist_ok=True)
2819
+ result = subprocess.run(
2820
+ ["git", "clone", "--quiet", remote, str(target)],
2821
+ capture_output=True,
2822
+ text=True,
2823
+ )
2824
+ if result.returncode == 0:
2825
+ console.print(f"[green]cloned[/green] {name}")
2826
+ cloned += 1
2827
+ else:
2828
+ console.print(f"[red]failed[/red] {name}: {result.stderr.strip()}")
2829
+ failed += 1
2830
+
2831
+ if not dry_run:
2832
+ console.print(
2833
+ f"\n[bold]{cloned} cloned, {skipped} skipped, {failed} failed[/bold]"
2834
+ )
2835
+ if failed:
2836
+ raise typer.Exit(1)
2837
+
2838
+
2839
+ @repos_app.command("pull-all")
2840
+ def repos_pull_all(
2841
+ dest: Path | None = typer.Argument(
2842
+ None,
2843
+ help=(
2844
+ "Workspace root override. When omitted, pulls each repo at "
2845
+ "its resolved local_path from the config."
2846
+ ),
2847
+ ),
2848
+ config_path: str | None = typer.Option(
2849
+ None,
2850
+ "--config",
2851
+ "-c",
2852
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
2853
+ ),
2854
+ filter_str: str | None = typer.Option(
2855
+ None, "--filter", "-f", help="Substring filter on repo name"
2856
+ ),
2857
+ dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show actions without running"),
2858
+ ) -> None:
2859
+ """Fast-forward pull every cloned repo at its resolved local_path
2860
+ (or DEST/<owner.lower()>/<repo> when DEST is given)."""
2861
+ import subprocess
2862
+
2863
+ pulled = skipped = dirty = failed = 0
2864
+
2865
+ for name, org, repo, _remote, local_path in _iter_repos(config_path, filter_str):
2866
+ target = _resolve_target(dest, org, repo, local_path)
2867
+ if not (target / ".git").exists():
2868
+ console.print(f"[dim]– {name} (not cloned)[/dim]")
2869
+ skipped += 1
2870
+ continue
2871
+ if dry_run:
2872
+ console.print(f"[blue]would pull[/blue] {target}")
2873
+ continue
2874
+ # Refuse to pull a dirty tree — fetch only.
2875
+ porcelain = subprocess.run(
2876
+ ["git", "-C", str(target), "status", "--porcelain"],
2877
+ capture_output=True,
2878
+ text=True,
2879
+ )
2880
+ if porcelain.returncode != 0:
2881
+ console.print(
2882
+ f"[red]failed[/red] {name}: git status — {porcelain.stderr.strip()}"
2883
+ )
2884
+ failed += 1
2885
+ continue
2886
+ if porcelain.stdout.strip():
2887
+ fetched = subprocess.run(
2888
+ ["git", "-C", str(target), "fetch", "--quiet"],
2889
+ capture_output=True,
2890
+ text=True,
2891
+ )
2892
+ if fetched.returncode != 0:
2893
+ console.print(
2894
+ f"[red]failed[/red] {name}: fetch — {fetched.stderr.strip()}"
2895
+ )
2896
+ failed += 1
2897
+ else:
2898
+ console.print(f"[yellow]dirty[/yellow] {name} — fetched only")
2899
+ dirty += 1
2900
+ continue
2901
+ result = subprocess.run(
2902
+ ["git", "-C", str(target), "pull", "--ff-only", "--quiet"],
2903
+ capture_output=True,
2904
+ text=True,
2905
+ )
2906
+ if result.returncode == 0:
2907
+ console.print(f"[green]pulled[/green] {name}")
2908
+ pulled += 1
2909
+ else:
2910
+ console.print(f"[red]failed[/red] {name}: {result.stderr.strip()}")
2911
+ failed += 1
2912
+
2913
+ if not dry_run:
2914
+ console.print(
2915
+ f"\n[bold]{pulled} pulled, {dirty} dirty, {skipped} skipped, "
2916
+ f"{failed} failed[/bold]"
2917
+ )
2918
+ if failed:
2919
+ raise typer.Exit(1)
2920
+
2921
+
2922
+ @repos_app.command("status")
2923
+ def repos_status(
2924
+ dest: Path | None = typer.Argument(
2925
+ None,
2926
+ help=(
2927
+ "Workspace root override. When omitted, inspects each repo "
2928
+ "at its resolved local_path from the config."
2929
+ ),
2930
+ ),
2931
+ config_path: str | None = typer.Option(
2932
+ None,
2933
+ "--config",
2934
+ "-c",
2935
+ help="Path to orchestrator.yaml (default: auto-discover; see $CTRLRELAY_CONFIG).",
2936
+ ),
2937
+ filter_str: str | None = typer.Option(
2938
+ None, "--filter", "-f", help="Substring filter on repo name"
2939
+ ),
2940
+ ) -> None:
2941
+ """Show clean/dirty/ahead/behind for every repo at its resolved
2942
+ local_path (or DEST/<owner.lower()>/<repo> when DEST is given)."""
2943
+ import subprocess
2944
+
2945
+ title = "Repo status" if dest is None else f"Repo status — {Path(dest).expanduser().resolve()}"
2946
+ table = Table(title=title)
2947
+ table.add_column("Repo", style="cyan")
2948
+ table.add_column("Branch", style="blue")
2949
+ table.add_column("State")
2950
+ table.add_column("Ahead/Behind", justify="right")
2951
+
2952
+ for name, org, repo, _remote, local_path in _iter_repos(config_path, filter_str):
2953
+ target = _resolve_target(dest, org, repo, local_path)
2954
+ if not (target / ".git").exists():
2955
+ table.add_row(name, "-", "[red]not cloned[/red]", "")
2956
+ continue
2957
+ branch = subprocess.run(
2958
+ ["git", "-C", str(target), "rev-parse", "--abbrev-ref", "HEAD"],
2959
+ capture_output=True,
2960
+ text=True,
2961
+ ).stdout.strip()
2962
+ porcelain = subprocess.run(
2963
+ ["git", "-C", str(target), "status", "--porcelain"],
2964
+ capture_output=True,
2965
+ text=True,
2966
+ ).stdout.strip()
2967
+ state = "[yellow]dirty[/yellow]" if porcelain else "[green]clean[/green]"
2968
+
2969
+ upstream = subprocess.run(
2970
+ ["git", "-C", str(target), "rev-parse", "--abbrev-ref", "@{upstream}"],
2971
+ capture_output=True,
2972
+ text=True,
2973
+ )
2974
+ ahead_behind = ""
2975
+ if upstream.returncode == 0:
2976
+ def _count(spec: str) -> int:
2977
+ proc = subprocess.run(
2978
+ ["git", "-C", str(target), "rev-list", "--count", spec],
2979
+ capture_output=True,
2980
+ text=True,
2981
+ )
2982
+ if proc.returncode != 0:
2983
+ return 0
2984
+ try:
2985
+ return int(proc.stdout.strip() or "0")
2986
+ except ValueError:
2987
+ return 0
2988
+
2989
+ ahead_n = _count("@{upstream}..HEAD")
2990
+ behind_n = _count("HEAD..@{upstream}")
2991
+ parts = []
2992
+ if ahead_n > 0:
2993
+ parts.append(f"[green]+{ahead_n}[/green]")
2994
+ if behind_n > 0:
2995
+ parts.append(f"[red]-{behind_n}[/red]")
2996
+ ahead_behind = " ".join(parts)
2997
+
2998
+ table.add_row(name, branch or "-", state, ahead_behind)
2999
+
3000
+ console.print(table)
3001
+
3002
+
3003
+ install_app = typer.Typer(
3004
+ help="Generate launchd / systemd unit files from in-package templates."
3005
+ )
3006
+ app.add_typer(install_app, name="install")
3007
+
3008
+
3009
+ def _print_install_summary(
3010
+ units: list, written: list, dry_run: bool
3011
+ ) -> None:
3012
+ from ctrlrelay.install import RenderedUnit # local to avoid CLI import cost
3013
+
3014
+ for unit in units:
3015
+ assert isinstance(unit, RenderedUnit)
3016
+ marker = "would write" if dry_run else "wrote"
3017
+ path_str = (
3018
+ f"[green]{marker}[/green] {unit.target_path}"
3019
+ if not dry_run or unit.target_path in written
3020
+ else f"[yellow]{marker}[/yellow] {unit.target_path}"
3021
+ )
3022
+ console.print(path_str)
3023
+ if unit.unresolved:
3024
+ console.print(
3025
+ f" [yellow]warning:[/yellow] unresolved template variables: "
3026
+ f"{', '.join('${'+v+'}' for v in unit.unresolved)}"
3027
+ )
3028
+ if "CTRLRELAY_TELEGRAM_TOKEN" in unit.unresolved:
3029
+ console.print(
3030
+ " [dim]hint: export CTRLRELAY_TELEGRAM_TOKEN before "
3031
+ "re-running, or edit the file by hand.[/dim]"
3032
+ )
3033
+
3034
+
3035
+ @install_app.command("launchd")
3036
+ def install_launchd(
3037
+ workdir: Path = typer.Option(
3038
+ Path.cwd(),
3039
+ "--workdir",
3040
+ "-w",
3041
+ help="Service WorkingDirectory (defaults to current directory).",
3042
+ ),
3043
+ label_prefix: str = typer.Option(
3044
+ "com.ctrlrelay",
3045
+ "--label-prefix",
3046
+ help="Reverse-DNS prefix for the launchd Label and filename.",
3047
+ ),
3048
+ poller_interval: int = typer.Option(
3049
+ 300, "--poller-interval", help="Poller --interval seconds."
3050
+ ),
3051
+ target_dir: Path | None = typer.Option(
3052
+ None,
3053
+ "--target-dir",
3054
+ help="Where to write plists (default: ~/Library/LaunchAgents).",
3055
+ ),
3056
+ force: bool = typer.Option(
3057
+ False, "--force", help="Overwrite existing plists if present."
3058
+ ),
3059
+ dry_run: bool = typer.Option(
3060
+ False,
3061
+ "--dry-run",
3062
+ help="Print what would be written without touching the filesystem.",
3063
+ ),
3064
+ ) -> None:
3065
+ """Render and install bridge + poller plists for macOS launchd.
3066
+
3067
+ After running, load the agents with:
3068
+ launchctl bootstrap gui/$(id -u) <plist-path>
3069
+ """
3070
+ from ctrlrelay.install import render_launchd, write_units
3071
+
3072
+ units = render_launchd(
3073
+ workdir=workdir,
3074
+ label_prefix=label_prefix,
3075
+ poller_interval=poller_interval,
3076
+ target_dir=target_dir,
3077
+ )
3078
+ written: list[Path] = []
3079
+ if not dry_run:
3080
+ try:
3081
+ written = write_units(units, overwrite=force)
3082
+ except FileExistsError as e:
3083
+ console.print(f"[red]error:[/red] {e}")
3084
+ raise typer.Exit(1)
3085
+ _print_install_summary(units, written, dry_run)
3086
+
3087
+
3088
+ @install_app.command("systemd")
3089
+ def install_systemd(
3090
+ workdir: Path = typer.Option(
3091
+ Path.cwd(),
3092
+ "--workdir",
3093
+ "-w",
3094
+ help="Service WorkingDirectory (defaults to current directory).",
3095
+ ),
3096
+ poller_interval: int = typer.Option(
3097
+ 300, "--poller-interval", help="Poller --interval seconds."
3098
+ ),
3099
+ target_dir: Path | None = typer.Option(
3100
+ None,
3101
+ "--target-dir",
3102
+ help="Where to write units (default: ~/.config/systemd/user).",
3103
+ ),
3104
+ force: bool = typer.Option(
3105
+ False, "--force", help="Overwrite existing unit files if present."
3106
+ ),
3107
+ dry_run: bool = typer.Option(
3108
+ False,
3109
+ "--dry-run",
3110
+ help="Print what would be written without touching the filesystem.",
3111
+ ),
3112
+ ) -> None:
3113
+ """Render and install bridge + poller systemd user units (Linux).
3114
+
3115
+ After running, enable with:
3116
+ systemctl --user daemon-reload
3117
+ systemctl --user enable --now ctrlrelay-bridge.service
3118
+ systemctl --user enable --now ctrlrelay-poller.service
3119
+ """
3120
+ from ctrlrelay.install import render_systemd, write_units
3121
+
3122
+ units = render_systemd(
3123
+ workdir=workdir,
3124
+ poller_interval=poller_interval,
3125
+ target_dir=target_dir,
3126
+ )
3127
+ written: list[Path] = []
3128
+ if not dry_run:
3129
+ try:
3130
+ written = write_units(units, overwrite=force)
3131
+ except FileExistsError as e:
3132
+ console.print(f"[red]error:[/red] {e}")
3133
+ raise typer.Exit(1)
3134
+ _print_install_summary(units, written, dry_run)
3135
+
3136
+
3137
+ if __name__ == "__main__":
3138
+ app()