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/models.py ADDED
@@ -0,0 +1,83 @@
1
+ """ORM models for the CLI's local SQLite database.
2
+
3
+ These are intentionally separate from the server-side app/models/ so the CLI
4
+ package has no dependency on app/. The schema is a simplified subset —
5
+ no MSSQL-specific types, no alembic migrations needed (tables are created
6
+ automatically via create_tables() on startup).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import uuid
12
+ from datetime import UTC, datetime
13
+
14
+ from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
15
+ from sqlalchemy.orm import Mapped, mapped_column
16
+
17
+ from cli.db import Base
18
+ from cli.fsm import TicketState
19
+
20
+
21
+ class Ticket(Base):
22
+ """A coding task submitted to the local agent."""
23
+
24
+ __tablename__ = "tickets"
25
+
26
+ id: Mapped[str] = mapped_column(
27
+ String(36), primary_key=True, default=lambda: str(uuid.uuid4())
28
+ )
29
+ title: Mapped[str] = mapped_column(String(500), nullable=False)
30
+ description: Mapped[str] = mapped_column(Text, nullable=False)
31
+ repo_url: Mapped[str] = mapped_column(String(1000), nullable=False)
32
+ branch_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
33
+ state: Mapped[str] = mapped_column(
34
+ String(50), nullable=False, default=TicketState.RECEIVED.value
35
+ )
36
+ error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
37
+ created_at: Mapped[datetime] = mapped_column(
38
+ DateTime, nullable=False, default=lambda: datetime.now(UTC)
39
+ )
40
+ updated_at: Mapped[datetime] = mapped_column(
41
+ DateTime, nullable=False, default=lambda: datetime.now(UTC)
42
+ )
43
+
44
+
45
+ class GraphContext(Base):
46
+ """Persists orchestrator state so it survives between runs."""
47
+
48
+ __tablename__ = "graph_contexts"
49
+ __table_args__ = (
50
+ UniqueConstraint("ticket_id", name="uq_graph_contexts_ticket_id"),
51
+ )
52
+
53
+ id: Mapped[str] = mapped_column(
54
+ String(36), primary_key=True, default=lambda: str(uuid.uuid4())
55
+ )
56
+ ticket_id: Mapped[str] = mapped_column(
57
+ String(36), ForeignKey("tickets.id"), nullable=False, index=True
58
+ )
59
+ current_state: Mapped[str] = mapped_column(String(50), nullable=False)
60
+ retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
61
+ container_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
62
+ pr_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
63
+ plan_json: Mapped[str | None] = mapped_column(Text, nullable=True)
64
+ opencode_session_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
65
+ logs: Mapped[str] = mapped_column(Text, nullable=False, default="")
66
+ workspace_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
67
+ # Comma-separated thread IDs that have already been responded to, so the
68
+ # agent does not re-address comments it has already handled.
69
+ seen_thread_ids: Mapped[str] = mapped_column(Text, nullable=False, default="")
70
+ default_branch: Mapped[str] = mapped_column(
71
+ String(255), nullable=False, default="main"
72
+ )
73
+ # JSON-serialised RepoContainerConfig — set during PREPARE_ENV and used
74
+ # by VALIDATE to build and run the repo's own container for checks.
75
+ repo_container_config_json: Mapped[str | None] = mapped_column(Text, nullable=True)
76
+ # PID of the background process running the orchestrator for this ticket.
77
+ # Set to os.getpid() at startup; cleared to NULL on terminal state.
78
+ # Used by `devvy status` and `devvy ps` to determine if a ticket is actively
79
+ # being processed or has crashed mid-run.
80
+ worker_pid: Mapped[int | None] = mapped_column(Integer, nullable=True)
81
+ updated_at: Mapped[datetime] = mapped_column(
82
+ DateTime, nullable=False, default=lambda: datetime.now(UTC)
83
+ )
cli/orchestrator.py ADDED
@@ -0,0 +1,530 @@
1
+ """Local orchestrator — drives a ticket through its full lifecycle without a server.
2
+
3
+ All code execution happens inside an ephemeral Docker container (devvy-worker:latest).
4
+ The orchestrator itself only manages state, persistence, and ADO API calls.
5
+
6
+ Flow:
7
+ RECEIVED → PREPARE_ENV (clone repo, start container)
8
+ → PLAN (opencode: explore + plan, no code changes)
9
+ → IMPLEMENT (opencode: implement the plan, same session)
10
+ → VALIDATE (pytest / ruff / mypy inside container)
11
+ → CREATE_PR (git push + ADO REST API)
12
+ → WAIT_FOR_REVIEW (poll ADO every 30s)
13
+ → RESPOND_TO_REVIEW (opencode: address comments)
14
+ → MERGED / FAILED (stop + remove container)
15
+
16
+ The FSM logic is copied here to avoid importing from app/ (CLI wheel has no
17
+ dependency on the server package).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import os
25
+ from collections.abc import Callable
26
+ from datetime import UTC, datetime
27
+
28
+ from cli import ado_client, local_runner
29
+ from cli.config import Config
30
+ from cli.db import SessionManager
31
+ from cli.fsm import (
32
+ VALID_TRANSITIONS,
33
+ InvalidTransitionError,
34
+ TicketState,
35
+ transition as _transition,
36
+ )
37
+ from cli.models import GraphContext, Ticket
38
+ from cli.prompts import parse_replies
39
+
40
+
41
+ _PR_POLL_INTERVAL = 30 # seconds between ADO status polls
42
+ _MAX_VALIDATE_RETRIES = 3
43
+
44
+
45
+ class Orchestrator:
46
+ """Drives a single ticket through its lifecycle inside a Docker container."""
47
+
48
+ def __init__(self, ticket: Ticket, context: GraphContext, config: Config) -> None:
49
+ self._ticket = ticket
50
+ self._ctx = context
51
+ self._cfg = config
52
+
53
+ # ------------------------------------------------------------------
54
+ # Public API
55
+ # ------------------------------------------------------------------
56
+
57
+ async def run(self, status_callback: Callable[[str], None] | None = None) -> None:
58
+ """Run the FSM from the current state to a terminal state."""
59
+ self._ctx.worker_pid = os.getpid()
60
+ await self._persist()
61
+ try:
62
+ while not TicketState(self._ctx.current_state).is_terminal:
63
+ await self._dispatch(
64
+ TicketState(self._ctx.current_state), status_callback
65
+ )
66
+ except asyncio.CancelledError:
67
+ raise
68
+ except Exception as exc:
69
+ await self._fail(str(exc))
70
+ finally:
71
+ # Clear the PID so status/ps can distinguish "done" from "crashed".
72
+ self._ctx.worker_pid = None
73
+ await self._persist()
74
+
75
+ # ------------------------------------------------------------------
76
+ # Dispatch
77
+ # ------------------------------------------------------------------
78
+
79
+ # Handler map is a class-level constant — it never varies per call or
80
+ # per instance, so there is no reason to rebuild it on every _dispatch().
81
+ _HANDLERS: dict[TicketState, str] = {
82
+ TicketState.RECEIVED: "_handle_received",
83
+ TicketState.PREPARE_ENV: "_handle_prepare_env",
84
+ TicketState.PLAN: "_handle_plan",
85
+ TicketState.IMPLEMENT: "_handle_implement",
86
+ TicketState.VALIDATE: "_handle_validate",
87
+ TicketState.CREATE_PR: "_handle_create_pr",
88
+ TicketState.WAIT_FOR_REVIEW: "_handle_wait_for_review",
89
+ TicketState.RESPOND_TO_REVIEW: "_handle_respond_to_review",
90
+ TicketState.MERGED: "_handle_merged",
91
+ TicketState.FAILED: "_handle_failed",
92
+ }
93
+
94
+ async def _dispatch(
95
+ self, state: TicketState, cb: Callable[[str], None] | None
96
+ ) -> None:
97
+ handler_name = self._HANDLERS[state]
98
+ if state == TicketState.WAIT_FOR_REVIEW:
99
+ # _poll_for_review calls cb itself on state transitions, so we
100
+ # pass cb through rather than calling it once after the handler.
101
+ await self._poll_for_review(cb)
102
+ else:
103
+ await getattr(self, handler_name)()
104
+ if cb:
105
+ cb(self._ctx.current_state)
106
+
107
+ async def _handle_wait_for_review(self) -> None:
108
+ """Placeholder — actual work is done by _poll_for_review via _dispatch."""
109
+ pass # _dispatch routes WAIT_FOR_REVIEW directly to _poll_for_review
110
+
111
+ # ------------------------------------------------------------------
112
+ # State handlers
113
+ # ------------------------------------------------------------------
114
+
115
+ async def _handle_received(self) -> None:
116
+ if not self._ticket.repo_url:
117
+ await self._fail("Ticket has no repo_url")
118
+ return
119
+ await self._set_state(TicketState.PREPARE_ENV)
120
+
121
+ async def _handle_prepare_env(self) -> None:
122
+ setup = await local_runner.prepare_workspace(
123
+ ticket_id=self._ticket.id,
124
+ repo_url=self._ticket.repo_url,
125
+ ado_pat=self._cfg.ado_pat,
126
+ env_file=self._cfg.env_file,
127
+ )
128
+ self._ticket.branch_name = setup.branch_name
129
+ self._ctx.workspace_path = str(setup.workspace)
130
+ self._ctx.container_id = setup.container_id
131
+ self._ctx.default_branch = setup.default_branch
132
+ self._ctx.repo_container_config_json = setup.repo_cfg.to_json()
133
+ self._append_log(
134
+ f"Workspace: {setup.workspace}\nBranch: {setup.branch_name}\n"
135
+ f"Default branch: {setup.default_branch}\nContainer: {setup.container_id[:12]}"
136
+ )
137
+ await self._persist()
138
+ await self._set_state(TicketState.PLAN)
139
+
140
+ async def _handle_plan(self) -> None:
141
+ """Ask opencode to explore the repo and produce a plan. No code changes yet."""
142
+ container_id = self._require_container()
143
+ prompt = (
144
+ f"You are an autonomous coding agent.\n\n"
145
+ f"Ticket title: {self._ticket.title}\n\n"
146
+ f"Ticket description:\n{self._ticket.description}\n\n"
147
+ f"Explore the repository at {local_runner.CONTAINER_WORKSPACE}, understand "
148
+ f"the existing codebase, and produce a detailed implementation plan. "
149
+ f"List the files you will create or modify and the exact changes needed. "
150
+ f"Do NOT make any code changes yet — planning only."
151
+ )
152
+ plan_text, session_id = await local_runner.run_opencode(
153
+ container_id=container_id,
154
+ prompt=prompt,
155
+ model=self._cfg.opencode_model,
156
+ )
157
+ self._ctx.plan_json = plan_text
158
+ self._ctx.opencode_session_id = session_id
159
+ self._append_log(f"Plan (session {session_id}):\n{plan_text}")
160
+ await self._persist()
161
+ await self._set_state(TicketState.IMPLEMENT)
162
+
163
+ async def _handle_implement(self) -> None:
164
+ """Continue the opencode session and implement the plan."""
165
+ container_id = self._require_container()
166
+ prompt = (
167
+ "Now implement the plan you just described. "
168
+ "Make all necessary code changes, create new files where required, "
169
+ "and ensure the implementation is complete and correct.\n\n"
170
+ "IMPORTANT: After making your changes, verify them by running the "
171
+ "repo's validation scripts inside the workspace in this order:\n\n"
172
+ f" 1. Format: bash {local_runner.CONTAINER_WORKSPACE}/src/scripts/format.sh\n"
173
+ f" 2. Code checks: bash {local_runner.CONTAINER_WORKSPACE}/src/scripts/code-checks.sh\n"
174
+ f" 3. Tests: bash {local_runner.CONTAINER_WORKSPACE}/src/scripts/run-tests.sh\n\n"
175
+ "Fix any failures before finishing. "
176
+ "You MAY create, update, or delete tests to keep the suite consistent with your changes."
177
+ )
178
+ output, session_id = await local_runner.run_opencode(
179
+ container_id=container_id,
180
+ prompt=prompt,
181
+ model=self._cfg.opencode_model,
182
+ session_id=self._ctx.opencode_session_id or None,
183
+ )
184
+ self._ctx.opencode_session_id = session_id
185
+ self._append_log(f"Implementation:\n{output}")
186
+ await local_runner.commit_if_changed(
187
+ container_id, f"agent: implement ticket {self._ticket.id[:8]}"
188
+ )
189
+ await self._set_state(TicketState.VALIDATE)
190
+
191
+ async def _handle_validate(self) -> None:
192
+ container_id = self._require_container()
193
+ if not self._ctx.repo_container_config_json:
194
+ await self._fail("No repo container config — PREPARE_ENV may have failed")
195
+ return
196
+ repo_cfg = local_runner.RepoContainerConfig.from_json(
197
+ self._ctx.repo_container_config_json
198
+ )
199
+ result = await local_runner.run_validation(repo_cfg)
200
+ self._append_log(f"Validation:\n{result.output}")
201
+
202
+ if result.passed:
203
+ self._ctx.retry_count = 0
204
+ if self._ctx.pr_number:
205
+ # PR already exists (post-review validation) — push the updated
206
+ # branch so the PR reflects the new commits, then go back to polling.
207
+ branch = self._branch_name
208
+ await local_runner.push_branch(container_id, branch)
209
+ await self._set_state(TicketState.WAIT_FOR_REVIEW)
210
+ else:
211
+ await self._set_state(TicketState.CREATE_PR)
212
+ return
213
+
214
+ self._ctx.retry_count += 1
215
+ if self._ctx.retry_count > _MAX_VALIDATE_RETRIES:
216
+ await self._fail(f"Validation failed after {_MAX_VALIDATE_RETRIES} retries")
217
+ return
218
+
219
+ fix_prompt = (
220
+ f"The validation checks failed. Fix ALL of the following issues:\n\n"
221
+ f"{result.output}\n\n"
222
+ f"Once fixed, verify by re-running the repo's validation scripts in order:\n\n"
223
+ f" 1. Format: bash {local_runner.CONTAINER_WORKSPACE}/src/scripts/format.sh\n"
224
+ f" 2. Code checks: bash {local_runner.CONTAINER_WORKSPACE}/src/scripts/code-checks.sh\n"
225
+ f" 3. Tests: bash {local_runner.CONTAINER_WORKSPACE}/src/scripts/run-tests.sh\n\n"
226
+ f"Fix everything — do not stop after fixing just one category. "
227
+ f"All scripts must exit 0 before you finish."
228
+ )
229
+ fix_output, session_id = await local_runner.run_opencode(
230
+ container_id=container_id,
231
+ prompt=fix_prompt,
232
+ model=self._cfg.opencode_model,
233
+ session_id=self._ctx.opencode_session_id or None,
234
+ )
235
+ self._ctx.opencode_session_id = session_id
236
+ self._append_log(f"Self-fix:\n{fix_output}")
237
+ await local_runner.commit_if_changed(
238
+ container_id,
239
+ f"agent: fix validation failures (attempt {self._ctx.retry_count})",
240
+ )
241
+ await self._persist()
242
+ # Stay in VALIDATE — the while loop re-dispatches
243
+
244
+ async def _generate_pr_description(self, template: str) -> str:
245
+ """
246
+ Ask opencode (continuing the existing session) to fill in *template*
247
+ based on the changes it just made.
248
+
249
+ Returns the filled-in description text. Falls back to the raw plan
250
+ text if opencode produces no output.
251
+ """
252
+ container_id = self._require_container()
253
+ prompt = (
254
+ "Fill in the following pull request template based on the changes "
255
+ "you just made to this repository. Return ONLY the filled-in "
256
+ "template text — no preamble, no extra commentary.\n\n"
257
+ f"Template:\n{template}"
258
+ )
259
+ description, session_id = await local_runner.run_opencode(
260
+ container_id=container_id,
261
+ prompt=prompt,
262
+ model=self._cfg.opencode_model,
263
+ session_id=self._ctx.opencode_session_id or None,
264
+ )
265
+ self._ctx.opencode_session_id = session_id
266
+ return description.strip() or (
267
+ self._ctx.plan_json or self._ticket.description or ""
268
+ )
269
+
270
+ async def _handle_create_pr(self) -> None:
271
+ container_id = self._require_container()
272
+ branch = self._branch_name
273
+ await local_runner.push_branch(container_id, branch)
274
+
275
+ template = local_runner.read_pr_template(self._ticket.id)
276
+ if template:
277
+ description = await self._generate_pr_description(template)
278
+ self._append_log(f"PR description generated from template:\n{description}")
279
+ else:
280
+ description = self._ctx.plan_json or self._ticket.description or ""
281
+
282
+ pr_number, pr_url = await ado_client.create_pr(
283
+ repo_url=self._ticket.repo_url,
284
+ branch=branch,
285
+ title=f"[Agent] {self._ticket.title}",
286
+ description=description,
287
+ ado_org_url=self._cfg.ado_org_url,
288
+ ado_project=self._cfg.ado_project,
289
+ ado_pat=self._cfg.ado_pat,
290
+ target_branch=self._ctx.default_branch or "main",
291
+ )
292
+ self._ctx.pr_number = pr_number
293
+ self._append_log(f"PR #{pr_number} created: {pr_url}")
294
+ await self._set_state(TicketState.WAIT_FOR_REVIEW)
295
+
296
+ async def _poll_for_review(self, cb: Callable[[str], None] | None) -> None:
297
+ """Poll ADO every 30s until the PR is merged, abandoned, or has new comments."""
298
+ pr_number = self._ctx.pr_number
299
+ if not pr_number:
300
+ await self._fail("No PR number in WAIT_FOR_REVIEW")
301
+ return
302
+
303
+ self._append_log("Waiting for PR review (polling ADO every 30s)...")
304
+ await self._persist()
305
+
306
+ while True:
307
+ await asyncio.sleep(_PR_POLL_INTERVAL)
308
+ try:
309
+ status = await ado_client.get_pr_status(
310
+ self._ticket.repo_url,
311
+ pr_number,
312
+ self._cfg.ado_org_url,
313
+ self._cfg.ado_project,
314
+ self._cfg.ado_pat,
315
+ )
316
+ except Exception as exc:
317
+ self._append_log(f"ADO poll error: {exc}")
318
+ continue
319
+
320
+ if status == "completed":
321
+ await self._set_state(TicketState.MERGED)
322
+ if cb:
323
+ cb(self._ctx.current_state)
324
+ return
325
+ elif status == "abandoned":
326
+ await self._fail("PR was abandoned")
327
+ return
328
+ else:
329
+ try:
330
+ comments = await ado_client.get_pr_comments(
331
+ self._ticket.repo_url,
332
+ pr_number,
333
+ self._cfg.ado_org_url,
334
+ self._cfg.ado_project,
335
+ self._cfg.ado_pat,
336
+ )
337
+ except Exception:
338
+ comments = []
339
+
340
+ seen_map = self._seen_thread_map()
341
+ new_threads = [
342
+ t
343
+ for t in comments
344
+ if max(c["comment_id"] for c in t["comments"])
345
+ > seen_map.get(t["thread_id"], 0)
346
+ ]
347
+ if new_threads:
348
+ await self._set_state(TicketState.RESPOND_TO_REVIEW)
349
+ if cb:
350
+ cb(self._ctx.current_state)
351
+ return
352
+
353
+ async def _handle_respond_to_review(self) -> None:
354
+ pr_number = self._ctx.pr_number
355
+ if not pr_number:
356
+ await self._fail("No PR number in RESPOND_TO_REVIEW")
357
+ return
358
+
359
+ container_id = self._require_container()
360
+ threads = await ado_client.get_pr_comments(
361
+ self._ticket.repo_url,
362
+ pr_number,
363
+ self._cfg.ado_org_url,
364
+ self._cfg.ado_project,
365
+ self._cfg.ado_pat,
366
+ )
367
+
368
+ seen_map = self._seen_thread_map()
369
+ new_threads = [
370
+ t
371
+ for t in threads
372
+ if max(c["comment_id"] for c in t["comments"])
373
+ > seen_map.get(t["thread_id"], 0)
374
+ ]
375
+
376
+ if not new_threads:
377
+ await self._set_state(TicketState.WAIT_FOR_REVIEW)
378
+ return
379
+
380
+ # Build a full thread history block for each new thread so OpenCode
381
+ # has full context (prior conversation + new reply).
382
+ thread_blocks = []
383
+ for t in new_threads:
384
+ header = f"thread_id={t['thread_id']}, file={t['file_path'] or 'general'}:"
385
+ lines = [header]
386
+ for c in t["comments"]:
387
+ lines.append(f" [{c['author']}] {c['body']}")
388
+ thread_blocks.append("\n".join(lines))
389
+ thread_section = "\n\n".join(thread_blocks)
390
+
391
+ prompt = (
392
+ "You have the following PR review threads to address. Each thread "
393
+ "shows the full conversation history — the last message in each "
394
+ "thread is the newest comment requiring your attention.\n\n"
395
+ f"{thread_section}\n\n"
396
+ "For each thread:\n"
397
+ "1. If you agree it requires a code change, make the change.\n"
398
+ "2. If you disagree, do NOT make the change — instead explain "
399
+ " your reasoning clearly and respectfully in your reply.\n"
400
+ "3. You MUST write a reply for every thread, whether you made a "
401
+ " change or not (e.g. 'Good catch, fixed.' or 'I disagree "
402
+ " because ...').\n\n"
403
+ "At the end of your response, output your replies in EXACTLY "
404
+ "this format (no extra blank lines between entries):\n\n"
405
+ "REPLIES:\n"
406
+ "thread_id=<id>: <your reply text>\n"
407
+ "thread_id=<id>: <your reply text>\n"
408
+ )
409
+
410
+ output, session_id = await local_runner.run_opencode(
411
+ container_id=container_id,
412
+ prompt=prompt,
413
+ model=self._cfg.opencode_model,
414
+ session_id=self._ctx.opencode_session_id or None,
415
+ )
416
+ self._ctx.opencode_session_id = session_id
417
+ self._append_log(f"Review response:\n{output}")
418
+
419
+ # Post a reply to each thread in ADO, then advance the high-water mark
420
+ # to include devvy's own reply comment ID so we don't re-process it.
421
+ replies = parse_replies(output)
422
+ for t in new_threads:
423
+ tid = t["thread_id"]
424
+ # High-water mark: max comment ID seen in this thread so far.
425
+ max_seen = max(c["comment_id"] for c in t["comments"])
426
+ message = replies.get(tid, "Reviewed and addressed.")
427
+ try:
428
+ new_comment_id = await ado_client.post_thread_reply(
429
+ repo_url=self._ticket.repo_url,
430
+ pr_number=pr_number,
431
+ thread_id=tid,
432
+ message=message,
433
+ ado_org_url=self._cfg.ado_org_url,
434
+ ado_project=self._cfg.ado_project,
435
+ ado_pat=self._cfg.ado_pat,
436
+ )
437
+ # Advance mark to include devvy's own reply so it isn't re-processed.
438
+ max_seen = max(max_seen, new_comment_id)
439
+ self._append_log(f"Posted reply to thread {tid}: {message}")
440
+ except Exception as exc:
441
+ self._append_log(f"Failed to post reply to thread {tid}: {exc}")
442
+ self._mark_comments_seen(tid, max_seen)
443
+
444
+ # Commit any code changes OpenCode made.
445
+ committed = await local_runner.commit_if_changed(
446
+ container_id, "agent: address PR review comments"
447
+ )
448
+
449
+ self._ctx.retry_count = 0
450
+ await self._persist()
451
+
452
+ if committed:
453
+ # Code changed — run validation before re-entering the review loop.
454
+ await self._set_state(TicketState.VALIDATE)
455
+ else:
456
+ # Pure discussion (replies only, no code changes) — skip CI and
457
+ # go straight back to waiting for the next reviewer action.
458
+ await self._set_state(TicketState.WAIT_FOR_REVIEW)
459
+
460
+ async def _handle_merged(self) -> None:
461
+ await local_runner.cleanup_workspace(self._ticket.id, self._ctx.container_id)
462
+ self._append_log("Ticket complete — PR merged.")
463
+ await self._persist()
464
+
465
+ async def _handle_failed(self) -> None:
466
+ await local_runner.cleanup_workspace(self._ticket.id, self._ctx.container_id)
467
+ await self._persist()
468
+
469
+ # ------------------------------------------------------------------
470
+ # Helpers
471
+ # ------------------------------------------------------------------
472
+
473
+ async def _set_state(self, target: TicketState) -> None:
474
+ current = TicketState(self._ctx.current_state)
475
+ new_state = _transition(current, target)
476
+ self._ctx.current_state = new_state.value
477
+ self._ticket.state = new_state.value
478
+ self._ticket.updated_at = datetime.now(UTC)
479
+ await self._persist()
480
+
481
+ async def _fail(self, reason: str) -> None:
482
+ self._ticket.error_message = reason
483
+ try:
484
+ await self._set_state(TicketState.FAILED)
485
+ except Exception:
486
+ self._ctx.current_state = TicketState.FAILED.value
487
+ self._ticket.state = TicketState.FAILED.value
488
+ await self._persist()
489
+ await self._handle_failed()
490
+
491
+ async def _persist(self) -> None:
492
+ self._ctx.updated_at = datetime.now(UTC)
493
+ async with SessionManager.session() as session:
494
+ session.add(self._ticket)
495
+ session.add(self._ctx)
496
+
497
+ def _require_container(self) -> str:
498
+ if not self._ctx.container_id:
499
+ raise RuntimeError("No container_id — PREPARE_ENV may have failed")
500
+ return self._ctx.container_id
501
+
502
+ @property
503
+ def _branch_name(self) -> str:
504
+ return self._ticket.branch_name or f"agent/{self._ticket.id[:8]}"
505
+
506
+ def _seen_thread_map(self) -> dict[str, int]:
507
+ """Return a mapping of thread_id → max comment_id seen.
508
+
509
+ Stored as a JSON object in ``seen_thread_ids``, e.g.::
510
+
511
+ {"64": 3, "65": 1, "66": 2}
512
+ """
513
+ raw = self._ctx.seen_thread_ids or ""
514
+ if not raw:
515
+ return {}
516
+ try:
517
+ return json.loads(raw)
518
+ except json.JSONDecodeError:
519
+ # Graceful fallback for any stale data from a previous format.
520
+ return {}
521
+
522
+ def _mark_comments_seen(self, thread_id: str, max_comment_id: int) -> None:
523
+ """Record that we have seen up to *max_comment_id* in *thread_id*."""
524
+ seen_map = self._seen_thread_map()
525
+ seen_map[thread_id] = max(seen_map.get(thread_id, 0), max_comment_id)
526
+ self._ctx.seen_thread_ids = json.dumps(seen_map)
527
+
528
+ def _append_log(self, message: str) -> None:
529
+ timestamp = datetime.now(UTC).isoformat()
530
+ self._ctx.logs = f"{self._ctx.logs}\n[{timestamp}] {message}".strip()
cli/prompts.py ADDED
@@ -0,0 +1,42 @@
1
+ """Prompt parsing helpers for the devvy CLI.
2
+
3
+ Contains pure-Python parsers for structured output produced by opencode,
4
+ kept separate from ADO API calls (ado_client.py) and orchestration logic.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ def parse_replies(output: str) -> dict[str, str]:
11
+ """Extract per-thread replies from OpenCode's structured output.
12
+
13
+ Looks for a block of the form::
14
+
15
+ REPLIES:
16
+ thread_id=abc123: Great suggestion, fixed.
17
+ thread_id=def456: I disagree because X.
18
+
19
+ Returns a dict mapping thread_id → reply text. Lines that don't match
20
+ the expected format are silently skipped.
21
+ """
22
+ replies: dict[str, str] = {}
23
+ in_block = False
24
+ for line in output.splitlines():
25
+ stripped = line.strip()
26
+ if stripped == "REPLIES:":
27
+ in_block = True
28
+ continue
29
+ if in_block:
30
+ # Stop at the next blank line or a line that looks like a new section
31
+ if not stripped:
32
+ break
33
+ if stripped.startswith("thread_id="):
34
+ # thread_id=<id>: <message>
35
+ rest = stripped[len("thread_id=") :]
36
+ if ":" in rest:
37
+ tid, _, msg = rest.partition(":")
38
+ tid = tid.strip()
39
+ msg = msg.strip()
40
+ if tid and msg:
41
+ replies[tid] = msg
42
+ return replies