devvy 0.1.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.
cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """CLI package."""
cli/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Allow ``python -m cli`` to invoke the devvy CLI.
2
+
3
+ This is the fallback entry-point used by ``devvy run --background`` when
4
+ the installed ``devvy`` script cannot be found on PATH.
5
+ """
6
+
7
+ from cli.main import app
8
+
9
+ app()
cli/ado_client.py ADDED
@@ -0,0 +1,212 @@
1
+ """Azure DevOps REST API helpers for the CLI orchestrator.
2
+
3
+ All functions are module-level and stateless — they receive credentials
4
+ explicitly so they can be used independently of the Orchestrator class and
5
+ tested in isolation.
6
+
7
+ Auth: ADO uses HTTP Basic with an empty username and a PAT as the password,
8
+ Base64-encoded as ``:<pat>``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ from urllib.parse import urlparse
15
+
16
+ import httpx
17
+
18
+
19
+ _ADO_API_VERSION = "7.1"
20
+
21
+
22
+ def ado_headers(pat: str) -> dict[str, str]:
23
+ token = base64.b64encode(f":{pat}".encode()).decode()
24
+ return {"Authorization": f"Basic {token}", "Content-Type": "application/json"}
25
+
26
+
27
+ def extract_repo_name(repo_url: str) -> str:
28
+ return urlparse(repo_url).path.rstrip("/").split("/")[-1]
29
+
30
+
31
+ def api_base(org_url: str, project: str, repo_name: str) -> str:
32
+ return f"{org_url.rstrip('/')}/{project}/_apis/git/repositories/{repo_name}"
33
+
34
+
35
+ async def get_existing_pr(
36
+ repo_url: str,
37
+ branch: str,
38
+ ado_org_url: str,
39
+ ado_project: str,
40
+ ado_pat: str,
41
+ ) -> tuple[int, str] | None:
42
+ """Look up an open PR for *branch* in ADO.
43
+
44
+ Returns (pr_number, pr_url) if one is found, otherwise None.
45
+ """
46
+ repo_name = extract_repo_name(repo_url)
47
+ url = (
48
+ f"{api_base(ado_org_url, ado_project, repo_name)}/pullrequests"
49
+ f"?sourceRefName=refs/heads/{branch}&status=active&api-version={_ADO_API_VERSION}"
50
+ )
51
+ async with httpx.AsyncClient(timeout=30) as client:
52
+ resp = await client.get(url, headers=ado_headers(ado_pat))
53
+ resp.raise_for_status()
54
+ data = resp.json()
55
+ prs = data.get("value", [])
56
+ if not prs:
57
+ return None
58
+ pr = prs[0]
59
+ pr_number = pr["pullRequestId"]
60
+ pr_url = f"{ado_org_url.rstrip('/')}/{ado_project}/_git/{repo_name}/pullrequest/{pr_number}"
61
+ return pr_number, pr_url
62
+
63
+
64
+ async def create_pr(
65
+ repo_url: str,
66
+ branch: str,
67
+ title: str,
68
+ description: str,
69
+ ado_org_url: str,
70
+ ado_project: str,
71
+ ado_pat: str,
72
+ target_branch: str = "main",
73
+ ) -> tuple[int, str]:
74
+ """Create a PR and return (pr_number, pr_url).
75
+
76
+ If ADO returns 400/409 (e.g. a PR already exists for this branch), looks
77
+ up and returns the existing PR instead of raising.
78
+ """
79
+ repo_name = extract_repo_name(repo_url)
80
+ url = f"{api_base(ado_org_url, ado_project, repo_name)}/pullrequests?api-version={_ADO_API_VERSION}"
81
+ payload = {
82
+ "title": title,
83
+ "description": description,
84
+ "sourceRefName": f"refs/heads/{branch}",
85
+ "targetRefName": f"refs/heads/{target_branch}",
86
+ }
87
+ async with httpx.AsyncClient(timeout=30) as client:
88
+ resp = await client.post(url, json=payload, headers=ado_headers(ado_pat))
89
+ if resp.status_code in (400, 409):
90
+ # PR likely already exists for this branch — look it up.
91
+ existing = await get_existing_pr(
92
+ repo_url, branch, ado_org_url, ado_project, ado_pat
93
+ )
94
+ if existing:
95
+ return existing
96
+ # No existing PR found — surface the original error.
97
+ resp.raise_for_status()
98
+ else:
99
+ resp.raise_for_status()
100
+ data = resp.json()
101
+ pr_number = data["pullRequestId"]
102
+ pr_url = f"{ado_org_url.rstrip('/')}/{ado_project}/_git/{repo_name}/pullrequest/{pr_number}"
103
+ return pr_number, pr_url
104
+
105
+
106
+ async def get_pr_status(
107
+ repo_url: str,
108
+ pr_number: int,
109
+ ado_org_url: str,
110
+ ado_project: str,
111
+ ado_pat: str,
112
+ ) -> str:
113
+ repo_name = extract_repo_name(repo_url)
114
+ url = (
115
+ f"{api_base(ado_org_url, ado_project, repo_name)}"
116
+ f"/pullrequests/{pr_number}?api-version={_ADO_API_VERSION}"
117
+ )
118
+ async with httpx.AsyncClient(timeout=30) as client:
119
+ resp = await client.get(url, headers=ado_headers(ado_pat))
120
+ resp.raise_for_status()
121
+ return resp.json().get("status", "unknown")
122
+
123
+
124
+ async def get_pr_comments(
125
+ repo_url: str,
126
+ pr_number: int,
127
+ ado_org_url: str,
128
+ ado_project: str,
129
+ ado_pat: str,
130
+ ) -> list[dict]:
131
+ """Return one dict per non-deleted, non-system thread.
132
+
133
+ Each dict has the shape::
134
+
135
+ {
136
+ "thread_id": "64",
137
+ "file_path": "src/app/foo.py", # or None for general comments
138
+ "comments": [
139
+ {"comment_id": 1, "author": "Alice", "body": "..."},
140
+ {"comment_id": 2, "author": "devvy", "body": "..."},
141
+ ],
142
+ }
143
+
144
+ Comments within each thread are ordered by ``id`` ascending so callers
145
+ can treat the last entry as the most recent.
146
+ """
147
+ repo_name = extract_repo_name(repo_url)
148
+ url = (
149
+ f"{api_base(ado_org_url, ado_project, repo_name)}"
150
+ f"/pullrequests/{pr_number}/threads?api-version={_ADO_API_VERSION}"
151
+ )
152
+ async with httpx.AsyncClient(timeout=30) as client:
153
+ resp = await client.get(url, headers=ado_headers(ado_pat))
154
+ resp.raise_for_status()
155
+ data = resp.json()
156
+
157
+ threads: list[dict] = []
158
+ for thread in data.get("value", []):
159
+ if thread.get("isDeleted"):
160
+ continue
161
+ thread_id = str(thread.get("id", ""))
162
+ file_path = (thread.get("threadContext") or {}).get("filePath")
163
+ comments = []
164
+ for comment in sorted(thread.get("comments", []), key=lambda c: c.get("id", 0)):
165
+ if comment.get("isDeleted") or comment.get("commentType") == "system":
166
+ continue
167
+ comments.append(
168
+ {
169
+ "comment_id": comment.get("id", 0),
170
+ "author": comment.get("author", {}).get("displayName", "unknown"),
171
+ "body": comment.get("content", ""),
172
+ }
173
+ )
174
+ if not comments:
175
+ continue
176
+ threads.append(
177
+ {
178
+ "thread_id": thread_id,
179
+ "file_path": file_path,
180
+ "comments": comments,
181
+ }
182
+ )
183
+ return threads
184
+
185
+
186
+ async def post_thread_reply(
187
+ repo_url: str,
188
+ pr_number: int,
189
+ thread_id: str,
190
+ message: str,
191
+ ado_org_url: str,
192
+ ado_project: str,
193
+ ado_pat: str,
194
+ ) -> int:
195
+ """Post a reply comment to an existing PR thread.
196
+
197
+ parentCommentId=1 attaches the reply to the first (root) comment in the
198
+ thread, which is what ADO expects for a standard reply.
199
+
200
+ Returns the ``id`` of the newly created comment so callers can advance
201
+ the high-water mark for that thread.
202
+ """
203
+ repo_name = extract_repo_name(repo_url)
204
+ url = (
205
+ f"{api_base(ado_org_url, ado_project, repo_name)}"
206
+ f"/pullrequests/{pr_number}/threads/{thread_id}/comments?api-version={_ADO_API_VERSION}"
207
+ )
208
+ payload = {"content": message, "parentCommentId": 1}
209
+ async with httpx.AsyncClient(timeout=30) as client:
210
+ resp = await client.post(url, json=payload, headers=ado_headers(ado_pat))
211
+ resp.raise_for_status()
212
+ return int(resp.json().get("id", 0))
@@ -0,0 +1 @@
1
+ """CLI commands package."""
cli/commands/init.py ADDED
@@ -0,0 +1,107 @@
1
+ """coding-agent init — configure the local CLI client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+
7
+ import typer
8
+
9
+ from cli.config import CONFIG_PATH, DEFAULT_MODEL
10
+ from cli.ui import console, pick_model
11
+
12
+ # Ordered list of models shown in the picker (provider/model format).
13
+ # Kept in sync with `opencode models` output for the github-copilot provider.
14
+ _MODELS = [
15
+ "github-copilot/claude-sonnet-4.6",
16
+ "github-copilot/claude-sonnet-4.5",
17
+ "github-copilot/claude-opus-4.6",
18
+ "github-copilot/claude-opus-4.5",
19
+ "github-copilot/claude-haiku-4.5",
20
+ "github-copilot/gpt-5",
21
+ "github-copilot/gpt-5.1",
22
+ "github-copilot/gpt-5.2",
23
+ "github-copilot/gpt-4.1",
24
+ "github-copilot/gpt-4o",
25
+ "github-copilot/gpt-5-mini",
26
+ "github-copilot/gpt-5.1-codex",
27
+ "github-copilot/gpt-5.1-codex-mini",
28
+ "github-copilot/gemini-2.5-pro",
29
+ "github-copilot/gemini-3-flash-preview",
30
+ "github-copilot/gemini-3-pro-preview",
31
+ ]
32
+
33
+
34
+ app = typer.Typer(help="Initialise the coding-agent CLI configuration.")
35
+
36
+
37
+ @app.callback(invoke_without_command=True)
38
+ def init(ctx: typer.Context) -> None:
39
+ """
40
+ Interactively configure the coding-agent CLI.
41
+
42
+ Writes all credentials and settings to ~/.coding-agent.toml.
43
+ """
44
+ console.print("\n[bold]Initialising coding-agent configuration…[/bold]\n")
45
+
46
+ # Load existing config so we can show current values as defaults
47
+ existing: dict = {}
48
+ if CONFIG_PATH.exists():
49
+ try:
50
+ with CONFIG_PATH.open("rb") as f:
51
+ existing = tomllib.load(f).get("coding_agent", {})
52
+ except Exception:
53
+ pass
54
+
55
+ current_model = existing.get("opencode_model", DEFAULT_MODEL)
56
+ default_model = current_model if current_model in _MODELS else DEFAULT_MODEL
57
+
58
+ opencode_model = pick_model(_MODELS, default_model)
59
+ console.print(
60
+ f"\n [bold green]✓[/bold green] Selected: [bold]{opencode_model}[/bold]\n"
61
+ )
62
+
63
+ ado_org_url = typer.prompt(
64
+ "Azure DevOps org URL (e.g. https://dev.azure.com/myorg)",
65
+ default=existing.get("ado_org_url", ""),
66
+ )
67
+ ado_project = typer.prompt(
68
+ "Azure DevOps project name",
69
+ default=existing.get("ado_project", ""),
70
+ )
71
+ ado_pat = typer.prompt(
72
+ "Azure DevOps personal access token",
73
+ hide_input=True,
74
+ )
75
+ repo_url = typer.prompt(
76
+ "Repository URL (e.g. https://dev.azure.com/myorg/myproject/_git/myrepo)",
77
+ default=existing.get("repo_url", ""),
78
+ )
79
+ env_file = typer.prompt(
80
+ "Path to repo .env file to inject into the workspace (leave blank to skip)",
81
+ default=existing.get("env_file", ""),
82
+ )
83
+
84
+ config = {
85
+ "opencode_model": opencode_model,
86
+ "ado_org_url": ado_org_url,
87
+ "ado_project": ado_project,
88
+ "ado_pat": ado_pat,
89
+ "repo_url": repo_url,
90
+ "env_file": env_file,
91
+ }
92
+
93
+ # Write as TOML manually (tomllib is read-only; avoid extra dep on tomli-w)
94
+ lines = ["[coding_agent]\n"]
95
+ for key, value in config.items():
96
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
97
+ lines.append(f'{key} = "{escaped}"\n')
98
+
99
+ CONFIG_PATH.write_text("".join(lines))
100
+
101
+ # Lock down permissions — config contains secrets
102
+ CONFIG_PATH.chmod(0o600)
103
+
104
+ console.print(
105
+ f"\n[bold green]✓[/bold green] Configuration written to [bold]{CONFIG_PATH}[/bold]"
106
+ )
107
+ console.print("[dim] Permissions set to 600 (owner read/write only).[/dim]")
cli/commands/logs.py ADDED
@@ -0,0 +1,36 @@
1
+ """coding-agent logs <id> — print orchestrator logs for a ticket from local DB."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import typer
8
+ from sqlalchemy import select
9
+
10
+ import cli.models # noqa: F401 — ensure tables are registered on Base.metadata
11
+ from cli.db import SessionManager, init_db
12
+ from cli.models import GraphContext, Ticket
13
+ from cli.ui import err_console, render_logs
14
+
15
+
16
+ def logs(ticket_id: str = typer.Argument(..., help="Ticket ID")) -> None:
17
+ """Print the agent logs for a ticket."""
18
+ asyncio.run(_logs_async(ticket_id))
19
+
20
+
21
+ async def _logs_async(ticket_id: str) -> None:
22
+ await init_db()
23
+
24
+ async with SessionManager.session() as session:
25
+ ticket = await session.get(Ticket, ticket_id)
26
+ if ticket is None:
27
+ err_console.print(f"[red]Ticket {ticket_id!r} not found.[/red]")
28
+ raise typer.Exit(1)
29
+
30
+ result = await session.execute(
31
+ select(GraphContext).where(GraphContext.ticket_id == ticket_id)
32
+ )
33
+ ctx = result.scalar_one_or_none()
34
+
35
+ log_text = (ctx.logs if ctx else "").strip()
36
+ render_logs(log_text)
cli/commands/ps.py ADDED
@@ -0,0 +1,72 @@
1
+ """devvy ps — list all in-progress (and recently crashed) tickets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from rich.table import Table
8
+ from sqlalchemy import select
9
+
10
+ import cli.models # noqa: F401 — ensure tables are registered on Base.metadata
11
+ from cli.db import SessionManager, init_db
12
+ from cli.fsm import TicketState
13
+ from cli.models import GraphContext, Ticket
14
+ from cli.ui import _pid_alive, console, state_text
15
+
16
+
17
+ def ps() -> None:
18
+ """List all tickets that are not yet in a terminal state."""
19
+ asyncio.run(_ps_async())
20
+
21
+
22
+ async def _ps_async() -> None:
23
+ await init_db()
24
+
25
+ terminal_values = {s.value for s in TicketState if s.is_terminal}
26
+
27
+ async with SessionManager.session() as session:
28
+ result = await session.execute(
29
+ select(Ticket, GraphContext)
30
+ .join(GraphContext, GraphContext.ticket_id == Ticket.id)
31
+ .where(Ticket.state.notin_(terminal_values))
32
+ .order_by(Ticket.created_at)
33
+ )
34
+ rows = result.all()
35
+
36
+ if not rows:
37
+ console.print("[dim]No active tickets.[/dim]")
38
+ return
39
+
40
+ table = Table(show_header=True, header_style="bold dim", box=None, padding=(0, 2))
41
+ table.add_column("ID", style="dim", no_wrap=True)
42
+ table.add_column("TITLE")
43
+ table.add_column("STATE")
44
+ table.add_column("PROCESS")
45
+ table.add_column("BRANCH", style="dim")
46
+
47
+ for ticket, ctx in rows:
48
+ pid = ctx.worker_pid
49
+
50
+ if pid is not None:
51
+ if _pid_alive(pid):
52
+ process_label = (
53
+ f"[bold green]Running[/bold green] [dim](pid {pid})[/dim]"
54
+ )
55
+ else:
56
+ process_label = f"[bold red]Crashed[/bold red] [dim](pid {pid})[/dim]"
57
+ else:
58
+ process_label = "[dim yellow]Idle / resumable[/dim yellow]"
59
+
60
+ table.add_row(
61
+ ticket.id[:8],
62
+ ticket.title,
63
+ state_text(ticket.state),
64
+ process_label,
65
+ ticket.branch_name or "—",
66
+ )
67
+
68
+ console.print(table)
69
+ console.print(
70
+ f"\n[dim]{len(rows)} active ticket{'s' if len(rows) != 1 else ''}. "
71
+ "Use [bold]devvy logs <id>[/bold] or [bold]devvy status <id>[/bold] for details.[/dim]"
72
+ )
cli/commands/resume.py ADDED
@@ -0,0 +1,105 @@
1
+ """devvy resume <ticket-id> — re-attach to an existing ticket and continue running."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import typer
8
+ from sqlalchemy import select
9
+
10
+ import cli.models # noqa: F401 — ensure tables are registered on Base.metadata
11
+ from cli.config import Config, ConfigNotFoundError, load_config
12
+ from cli.db import SessionManager, init_db
13
+ from cli.fsm import TicketState
14
+ from cli.models import GraphContext, Ticket
15
+ from cli.orchestrator import Orchestrator
16
+ from cli import local_runner
17
+ from cli.ui import ProgressTracker, console, err_console, print_resume_banner
18
+
19
+
20
+ def resume(
21
+ ticket_id: str = typer.Argument(..., help="Ticket ID to resume"),
22
+ ) -> None:
23
+ """Resume a previously started ticket from its last persisted state."""
24
+ try:
25
+ config = load_config()
26
+ except ConfigNotFoundError as exc:
27
+ typer.echo(str(exc), err=True)
28
+ raise typer.Exit(1)
29
+ asyncio.run(_resume_async(ticket_id, config))
30
+
31
+
32
+ async def _resume_async(ticket_id: str, config: Config) -> None:
33
+ await init_db()
34
+
35
+ async with SessionManager.session() as session:
36
+ ticket = await session.get(Ticket, ticket_id)
37
+ if ticket is None:
38
+ err_console.print(f"[red]Ticket {ticket_id!r} not found.[/red]")
39
+ raise typer.Exit(1)
40
+
41
+ result = await session.execute(
42
+ select(GraphContext).where(GraphContext.ticket_id == ticket_id)
43
+ )
44
+ ctx = result.scalar_one_or_none()
45
+ if ctx is None:
46
+ err_console.print(f"[red]No context found for ticket {ticket_id!r}.[/red]")
47
+ raise typer.Exit(1)
48
+
49
+ state = TicketState(ctx.current_state)
50
+ if state.is_terminal:
51
+ err_console.print(
52
+ f"[yellow]Ticket {ticket_id[:8]} is already in terminal state "
53
+ f"{state.value} — nothing to resume.[/yellow]"
54
+ )
55
+ raise typer.Exit(1)
56
+
57
+ print_resume_banner(
58
+ ticket_id=ticket_id,
59
+ title=ticket.title,
60
+ state=ctx.current_state,
61
+ branch=ticket.branch_name,
62
+ pr_number=ctx.pr_number,
63
+ )
64
+
65
+ # Re-attach to the running container or spawn a fresh one over the
66
+ # existing workspace. If the workspace is gone, recover_container()
67
+ # raises a RuntimeError with a clear message.
68
+ try:
69
+ new_container_id, fresh = await local_runner.recover_container(
70
+ ticket_id=ticket_id,
71
+ container_id=ctx.container_id,
72
+ repo_url=ticket.repo_url,
73
+ ado_pat=config.ado_pat,
74
+ )
75
+ except RuntimeError as exc:
76
+ err_console.print(f"[red]Cannot resume: {exc}[/red]")
77
+ raise typer.Exit(1)
78
+
79
+ if fresh:
80
+ console.print(
81
+ f" [dim]Spawned fresh container [bold]{new_container_id[:12]}[/bold] "
82
+ f"(old container was gone).[/dim]"
83
+ )
84
+ ctx.container_id = new_container_id
85
+ # Stale session ID is useless without the old container's opencode state.
86
+ ctx.opencode_session_id = None
87
+ async with SessionManager.session() as session:
88
+ session.add(ctx)
89
+ session.add(ticket)
90
+ else:
91
+ console.print(
92
+ f" [dim]Re-attached to existing container [bold]{new_container_id[:12]}[/bold].[/dim]"
93
+ )
94
+
95
+ console.print(
96
+ f" [dim]Running from state: [bold]{ctx.current_state}[/bold][/dim]\n"
97
+ )
98
+
99
+ tracker = ProgressTracker()
100
+ orchestrator = Orchestrator(ticket, ctx, config)
101
+ await orchestrator.run(status_callback=tracker.on_state_change)
102
+ tracker.finish(ticket)
103
+
104
+ if ticket.state != TicketState.MERGED.value:
105
+ raise typer.Exit(1)
cli/commands/run.py ADDED
@@ -0,0 +1,135 @@
1
+ """coding-agent run — submit a coding task and run it locally."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import uuid
10
+ from datetime import UTC, datetime
11
+
12
+ import typer
13
+
14
+ from cli.config import Config, ConfigNotFoundError, load_config
15
+ from cli.db import SessionManager, init_db
16
+ from cli.fsm import TicketState
17
+ from cli.models import GraphContext, Ticket
18
+ from cli.orchestrator import Orchestrator
19
+ from cli.ui import ProgressTracker, console, print_run_banner
20
+
21
+
22
+ def run(
23
+ title: str = typer.Option(None, "--title", "-t", help="Ticket title"),
24
+ description: str = typer.Option(
25
+ None, "--description", "-D", help="Ticket description"
26
+ ),
27
+ background: bool = typer.Option(
28
+ False,
29
+ "--background",
30
+ "-d",
31
+ is_flag=True,
32
+ help="Detach and run in the background; returns the ticket ID immediately.",
33
+ ),
34
+ ) -> None:
35
+ """Run a coding task locally — no server required."""
36
+ try:
37
+ config = load_config()
38
+ except ConfigNotFoundError as exc:
39
+ typer.echo(str(exc), err=True)
40
+ raise typer.Exit(1)
41
+
42
+ if not title:
43
+ title = typer.prompt("Ticket title")
44
+ if not description:
45
+ description = typer.prompt("Ticket description")
46
+
47
+ if background:
48
+ _run_in_background(title, description)
49
+ return
50
+
51
+ asyncio.run(_run_async(title, description, config))
52
+
53
+
54
+ def _run_in_background(title: str, description: str) -> None:
55
+ """Fork a detached child process that runs the orchestrator and return immediately.
56
+
57
+ Re-executes the same ``devvy run`` command *without* ``--background`` so the
58
+ child goes through the normal foreground code path. ``start_new_session=True``
59
+ detaches the child from the controlling terminal on Linux and macOS, meaning it
60
+ keeps running after the parent (and its terminal) exit.
61
+ """
62
+ cmd = [
63
+ sys.executable,
64
+ "-m",
65
+ "cli", # invokes src/cli/__main__.py
66
+ "run",
67
+ "--title",
68
+ title,
69
+ "--description",
70
+ description,
71
+ ]
72
+
73
+ # Prefer the installed entry-point script when available — it's more robust
74
+ # than relying on -m cli.main_runner being importable.
75
+ devvy_exe = shutil.which("devvy")
76
+ if devvy_exe:
77
+ cmd = [devvy_exe, "run", "--title", title, "--description", description]
78
+
79
+ proc = subprocess.Popen(
80
+ cmd,
81
+ stdin=subprocess.DEVNULL,
82
+ stdout=subprocess.DEVNULL,
83
+ stderr=subprocess.DEVNULL,
84
+ start_new_session=True, # detach from controlling terminal
85
+ close_fds=True,
86
+ )
87
+
88
+ console.print(
89
+ f"[bold green]Started background ticket[/bold green] (pid [dim]{proc.pid}[/dim])\n"
90
+ f" [dim]title:[/dim] {title}\n\n"
91
+ f" Check progress:\n"
92
+ f" [bold]devvy ps[/bold]\n"
93
+ f" [bold]devvy status <ticket-id>[/bold]\n"
94
+ f" [bold]devvy logs <ticket-id>[/bold]"
95
+ )
96
+
97
+
98
+ async def _run_async(title: str, description: str, config: Config) -> None:
99
+ await init_db()
100
+
101
+ now = datetime.now(UTC)
102
+ ticket_id = str(uuid.uuid4())
103
+
104
+ ticket = Ticket(
105
+ id=ticket_id,
106
+ title=title,
107
+ description=description,
108
+ repo_url=config.repo_url,
109
+ state=TicketState.RECEIVED.value,
110
+ created_at=now,
111
+ updated_at=now,
112
+ )
113
+ context = GraphContext(
114
+ id=str(uuid.uuid4()),
115
+ ticket_id=ticket_id,
116
+ current_state=TicketState.RECEIVED.value,
117
+ retry_count=0,
118
+ logs="",
119
+ updated_at=now,
120
+ )
121
+
122
+ async with SessionManager.session() as session:
123
+ session.add(ticket)
124
+ session.add(context)
125
+
126
+ print_run_banner(ticket_id, title, config.repo_url)
127
+ console.print("[dim]Starting agent…[/dim]\n")
128
+
129
+ tracker = ProgressTracker()
130
+ orchestrator = Orchestrator(ticket, context, config)
131
+ await orchestrator.run(status_callback=tracker.on_state_change)
132
+ tracker.finish(ticket)
133
+
134
+ if ticket.state != TicketState.MERGED.value:
135
+ raise typer.Exit(1)