agentic-devtools 0.2.332__py3-none-any.whl → 0.2.334__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 (30) hide show
  1. agentic_devtools/_version.py +2 -2
  2. agentic_devtools/adapters/__init__.py +8 -0
  3. agentic_devtools/adapters/idempotency_query_provider.py +64 -0
  4. agentic_devtools/adapters/issue_provider.py +65 -0
  5. agentic_devtools/adapters/jira_provider.py +140 -75
  6. agentic_devtools/adapters/operation_plan.py +111 -0
  7. agentic_devtools/adapters/orchestration_key.py +39 -15
  8. agentic_devtools/adapters/plan_manifest.py +437 -0
  9. agentic_devtools/orchestration/graph_builder.py +15 -5
  10. agentic_devtools/orchestration/nodes/__init__.py +31 -0
  11. agentic_devtools/orchestration/nodes/_helpers.py +261 -0
  12. agentic_devtools/orchestration/nodes/checklist_creation.py +139 -0
  13. agentic_devtools/orchestration/nodes/commit.py +169 -0
  14. agentic_devtools/orchestration/nodes/completion.py +163 -0
  15. agentic_devtools/orchestration/nodes/implementation.py +395 -0
  16. agentic_devtools/orchestration/nodes/implementation_review.py +126 -0
  17. agentic_devtools/orchestration/nodes/initiate.py +136 -0
  18. agentic_devtools/orchestration/nodes/planning.py +221 -0
  19. agentic_devtools/orchestration/nodes/pull_request.py +256 -0
  20. agentic_devtools/orchestration/nodes/retrieve.py +198 -0
  21. agentic_devtools/orchestration/nodes/setup.py +123 -0
  22. agentic_devtools/orchestration/nodes/verification.py +96 -0
  23. agentic_devtools/orchestration/pilot_workflow.py +53 -21
  24. agentic_devtools/orchestration/runner.py +62 -9
  25. agentic_devtools/orchestration/state_schema.py +24 -0
  26. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/METADATA +1 -1
  27. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/RECORD +30 -14
  28. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/WHEEL +0 -0
  29. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/entry_points.txt +0 -0
  30. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,198 @@
1
+ """Retrieve node: fetch issue details from Jira or GitHub.
2
+
3
+ Handles both Jira (via ``agdt-get-jira-issue``) and GitHub
4
+ (via ``GitHubIssuesAdapter.get_issue()``) issue retrieval paths.
5
+ Normalizes issue data into a common state format.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from agentic_devtools.cli.github.repo_resolution import resolve_github_repo_safe
15
+ from agentic_devtools.cli.jira.adf import _convert_adf_to_text
16
+ from agentic_devtools.orchestration.nodes._helpers import (
17
+ detect_issue_provider,
18
+ normalize_issue_key,
19
+ run_command,
20
+ utc_now,
21
+ )
22
+ from agentic_devtools.orchestration.state_schema import WorkOnIssueState
23
+
24
+
25
+ def retrieve_node(state: WorkOnIssueState) -> dict[str, Any]:
26
+ """Fetch issue details and store normalized data in state.
27
+
28
+ Dispatches to Jira or GitHub retrieval based on ``issue_provider``.
29
+ """
30
+ issue_key = state.get("issue_key", "")
31
+ # Fail fast when issue_key is missing, blank, or not a string — provider
32
+ # detection and Jira/GitHub retrieval both require a valid key.
33
+ if not isinstance(issue_key, str):
34
+ return _error_result("issue_key is required and must be a non-empty string")
35
+ issue_key = issue_key.strip()
36
+ if not issue_key:
37
+ return _error_result("issue_key is required and must be a non-empty string")
38
+ raw_issue_provider = state.get("issue_provider")
39
+ issue_provider = (
40
+ raw_issue_provider
41
+ if isinstance(raw_issue_provider, str) and raw_issue_provider in {"jira", "github"}
42
+ else detect_issue_provider(issue_key)
43
+ )
44
+
45
+ if issue_provider == "jira":
46
+ result = _retrieve_jira(issue_key)
47
+ else:
48
+ result = _retrieve_github(issue_key)
49
+
50
+ # Propagate the (potentially derived) issue_provider back to state so downstream
51
+ # nodes (e.g. pull_request_node) don't default to Jira/Azure DevOps when
52
+ # issue_provider was absent or corrupted in the checkpoint.
53
+ result["issue_provider"] = issue_provider
54
+ return result
55
+
56
+
57
+ def _retrieve_jira(issue_key: str) -> dict[str, Any]:
58
+ """Retrieve issue details from Jira via agdt commands."""
59
+ # Set the issue key in state
60
+ set_result = run_command(["agdt-set", "jira.issue_key", issue_key])
61
+ if set_result.returncode != 0:
62
+ return _error_result(f"Failed to set jira.issue_key: {set_result.stderr.strip()}")
63
+
64
+ # Invoke agdt-get-jira-issue (background task)
65
+ get_result = run_command(["agdt-get-jira-issue"])
66
+ if get_result.returncode != 0:
67
+ return _error_result(f"Failed to invoke agdt-get-jira-issue: {get_result.stderr.strip()}")
68
+
69
+ # Wait for task completion
70
+ wait_result = run_command(["agdt-task-wait"], timeout=120)
71
+ if wait_result.returncode != 0:
72
+ return _error_result(f"agdt-task-wait failed: {wait_result.stderr.strip()}")
73
+
74
+ # Read the output file
75
+ from agentic_devtools.state import get_state_dir
76
+
77
+ state_dir = get_state_dir()
78
+ output_file = Path(state_dir) / "temp-get-issue-details-response.json"
79
+
80
+ if not output_file.exists():
81
+ return _error_result(f"Issue details output file not found: {output_file}")
82
+
83
+ try:
84
+ issue_data = json.loads(output_file.read_text(encoding="utf-8"))
85
+ except (json.JSONDecodeError, OSError) as exc:
86
+ return _error_result(f"Failed to parse issue details: {exc}")
87
+ if not isinstance(issue_data, dict):
88
+ return _error_result("Failed to parse issue details: expected a JSON object.")
89
+
90
+ # Normalize issue data
91
+ normalized = _normalize_jira_issue(issue_data, issue_key)
92
+
93
+ return {
94
+ "step": "retrieve",
95
+ "error": None,
96
+ "issue_data": normalized,
97
+ "issue_retrieved": True,
98
+ "events": [
99
+ {
100
+ "event": "retrieve_completed",
101
+ "timestamp": utc_now(),
102
+ "signals": {"provider": "jira", "issue_key": issue_key},
103
+ }
104
+ ],
105
+ }
106
+
107
+
108
+ def _retrieve_github(issue_key: str) -> dict[str, Any]:
109
+ """Retrieve issue details from GitHub via adapter."""
110
+ try:
111
+ from agentic_devtools.adapters.github_adapter import GitHubIssuesAdapter
112
+
113
+ normalized_issue_key = normalize_issue_key(issue_key)
114
+ if not normalized_issue_key:
115
+ return _error_result("GitHub issue retrieval failed: issue key is empty after normalization.")
116
+ repo = resolve_github_repo_safe()
117
+ if not repo:
118
+ return {
119
+ "step": "retrieve",
120
+ "error": "Cannot resolve GitHub repository from state or git remote.",
121
+ "issue_data": {},
122
+ "issue_retrieved": False,
123
+ "events": [
124
+ {
125
+ "event": "retrieve_failed",
126
+ "timestamp": utc_now(),
127
+ "signals": {"provider": "github", "issue_key": issue_key},
128
+ }
129
+ ],
130
+ }
131
+
132
+ adapter = GitHubIssuesAdapter(repo=repo)
133
+ issue_data = adapter.get_issue(normalized_issue_key)
134
+
135
+ normalized: dict[str, Any] = {
136
+ "key": normalized_issue_key,
137
+ "provider": "github",
138
+ "summary": issue_data.get("title", ""),
139
+ "description": issue_data.get("description", ""),
140
+ "status": issue_data.get("status", "open"),
141
+ "labels": issue_data.get("labels", []),
142
+ }
143
+
144
+ return {
145
+ "step": "retrieve",
146
+ "error": None,
147
+ "issue_data": normalized,
148
+ "issue_retrieved": True,
149
+ "events": [
150
+ {
151
+ "event": "retrieve_completed",
152
+ "timestamp": utc_now(),
153
+ "signals": {"provider": "github", "issue_key": issue_key},
154
+ }
155
+ ],
156
+ }
157
+ except Exception as exc:
158
+ return _error_result(f"GitHub issue retrieval failed: {exc}")
159
+
160
+
161
+ def _normalize_jira_issue(raw: dict[str, Any], issue_key: str) -> dict[str, Any]:
162
+ """Normalize raw Jira API response to common format."""
163
+ raw_fields = raw.get("fields")
164
+ fields: dict[str, Any] = raw_fields if isinstance(raw_fields, dict) else {}
165
+ status_field = fields.get("status")
166
+ status_name = status_field.get("name", "") if isinstance(status_field, dict) else ""
167
+ issuetype_field = fields.get("issuetype")
168
+ issue_type_name = issuetype_field.get("name", "") if isinstance(issuetype_field, dict) else ""
169
+ parent_field = fields.get("parent")
170
+ parent_key = parent_field.get("key") if isinstance(parent_field, dict) else None
171
+ labels = fields.get("labels", [])
172
+ return {
173
+ "key": issue_key,
174
+ "provider": "jira",
175
+ "summary": _convert_adf_to_text(fields.get("summary")),
176
+ "description": _convert_adf_to_text(fields.get("description")),
177
+ "status": status_name,
178
+ "issue_type": issue_type_name,
179
+ "labels": labels if isinstance(labels, list) else [],
180
+ "parent_key": parent_key,
181
+ }
182
+
183
+
184
+ def _error_result(message: str) -> dict[str, Any]:
185
+ """Build an error result dict."""
186
+ return {
187
+ "step": "retrieve",
188
+ "error": message,
189
+ "issue_data": {},
190
+ "issue_retrieved": False,
191
+ "events": [
192
+ {
193
+ "event": "retrieve_failed",
194
+ "timestamp": utc_now(),
195
+ "signals": {"error": message},
196
+ }
197
+ ],
198
+ }
@@ -0,0 +1,123 @@
1
+ """Setup node: validate branch context and create branch if needed.
2
+
3
+ Validates that the current git branch contains the issue key as a
4
+ slash-delimited segment (case-insensitive). If validation fails, creates
5
+ a new branch named ``feature/{issue_key}/implementation`` in the current
6
+ repository.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from agentic_devtools.orchestration.nodes._helpers import (
14
+ normalize_issue_key,
15
+ run_command,
16
+ utc_now,
17
+ )
18
+ from agentic_devtools.orchestration.state_schema import WorkOnIssueState
19
+
20
+
21
+ def _branch_targets_issue(branch_name: str, normalized_issue_key: str) -> bool:
22
+ """Return whether a slash-delimited branch name targets the given issue key."""
23
+ normalized_segments = [segment.strip().lower() for segment in branch_name.split("/") if segment.strip()]
24
+ return normalized_issue_key.lower() in normalized_segments
25
+
26
+
27
+ def setup_node(state: WorkOnIssueState) -> dict[str, Any]:
28
+ """Validate worktree/branch and create if needed.
29
+
30
+ Checks that the current git branch contains the issue key as a
31
+ slash-delimited branch segment (case-insensitive). If not, attempts
32
+ to create a new branch.
33
+ """
34
+ issue_key = state.get("issue_key", "")
35
+ if not isinstance(issue_key, str):
36
+ type_name = type(issue_key).__name__
37
+ return {
38
+ "step": "setup",
39
+ "error": "issue_key must be a string; cannot determine target branch",
40
+ "events": [
41
+ {
42
+ "event": "setup_failed",
43
+ "timestamp": utc_now(),
44
+ "signals": {"error": f"issue_key validation failed: expected string, got {type_name}"},
45
+ }
46
+ ],
47
+ }
48
+
49
+ normalized_key = normalize_issue_key(issue_key)
50
+
51
+ if not normalized_key:
52
+ return {
53
+ "step": "setup",
54
+ "error": "issue_key is missing or empty; cannot determine target branch",
55
+ "events": [
56
+ {
57
+ "event": "setup_failed",
58
+ "timestamp": utc_now(),
59
+ "signals": {"error": "issue_key is missing or empty"},
60
+ }
61
+ ],
62
+ }
63
+
64
+ # Get current branch name
65
+ result = run_command(["git", "rev-parse", "--abbrev-ref", "HEAD"])
66
+ if result.returncode != 0:
67
+ return {
68
+ "step": "setup",
69
+ "error": f"Failed to determine current branch: {result.stderr.strip()}",
70
+ "events": [
71
+ {
72
+ "event": "setup_failed",
73
+ "timestamp": utc_now(),
74
+ "signals": {"error": result.stderr.strip()},
75
+ }
76
+ ],
77
+ }
78
+
79
+ current_branch = result.stdout.strip()
80
+
81
+ # Check if branch contains the issue key as an exact branch segment.
82
+ if _branch_targets_issue(current_branch, normalized_key):
83
+ return {
84
+ "step": "setup",
85
+ "error": None,
86
+ "setup_complete": True,
87
+ "events": [
88
+ {
89
+ "event": "setup_completed",
90
+ "timestamp": utc_now(),
91
+ "signals": {"branch": current_branch, "action": "validated"},
92
+ }
93
+ ],
94
+ }
95
+
96
+ # Branch doesn't contain issue key — create a new branch
97
+ new_branch = f"feature/{normalized_key}/implementation"
98
+ create_result = run_command(["git", "checkout", "-b", new_branch])
99
+ if create_result.returncode != 0:
100
+ return {
101
+ "step": "setup",
102
+ "error": f"Failed to create branch {new_branch}: {create_result.stderr.strip()}",
103
+ "events": [
104
+ {
105
+ "event": "setup_failed",
106
+ "timestamp": utc_now(),
107
+ "signals": {"error": create_result.stderr.strip()},
108
+ }
109
+ ],
110
+ }
111
+
112
+ return {
113
+ "step": "setup",
114
+ "error": None,
115
+ "setup_complete": True,
116
+ "events": [
117
+ {
118
+ "event": "setup_completed",
119
+ "timestamp": utc_now(),
120
+ "signals": {"branch": new_branch, "action": "created"},
121
+ }
122
+ ],
123
+ }
@@ -0,0 +1,96 @@
1
+ """Verification node: run quality gates and track retry count.
2
+
3
+ Executes ``bash scripts/targeted-checks.sh`` and evaluates results.
4
+ On failure, increments retry count and returns an error. Routing back
5
+ to implementation (when retries remain) or to error_handler (when the
6
+ budget is exhausted) is handled by the graph routing functions in
7
+ ``pilot_workflow.py``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from agentic_devtools.orchestration.nodes._helpers import run_command, utc_now
15
+ from agentic_devtools.orchestration.state_schema import WorkOnIssueState
16
+
17
+ # Maximum characters stored in state for verification output.
18
+ # Full output (which may include pytest traces and coverage reports) is
19
+ # truncated before persisting to avoid bloating checkpoints.
20
+ _MAX_STORED_OUTPUT_CHARS = 4000
21
+ # Prefix prepended to the stored tail when output is truncated.
22
+ _TRUNCATION_PREFIX = "[... truncated ...]\n"
23
+
24
+
25
+ def verification_node(state: WorkOnIssueState) -> dict[str, Any]:
26
+ """Execute quality gates and evaluate pass/fail.
27
+
28
+ Runs ``bash scripts/targeted-checks.sh`` in default check mode.
29
+ Exit code 0 means all checks pass; exit 1-9 indicates failed check count.
30
+ """
31
+ retry_count = _normalize_retry_count(state.get("retry_count", 0))
32
+
33
+ # Execute quality gates
34
+ result = run_command(
35
+ ["bash", "scripts/targeted-checks.sh"],
36
+ timeout=300,
37
+ )
38
+
39
+ full_output = result.stdout + result.stderr
40
+ # Truncate for state storage; full output may include large pytest/coverage reports.
41
+ # Prepend a marker so consumers can tell the output was clipped.
42
+ if len(full_output) > _MAX_STORED_OUTPUT_CHARS:
43
+ tail_chars = _MAX_STORED_OUTPUT_CHARS - len(_TRUNCATION_PREFIX)
44
+ stored_output = _TRUNCATION_PREFIX + full_output[-tail_chars:]
45
+ else:
46
+ stored_output = full_output
47
+
48
+ if result.returncode == 0:
49
+ # All checks passed
50
+ return {
51
+ "step": "verification",
52
+ "error": None,
53
+ "retry_count": retry_count,
54
+ "verification_output": stored_output,
55
+ "events": [
56
+ {
57
+ "event": "verification_passed",
58
+ "timestamp": utc_now(),
59
+ "signals": {"exit_code": 0},
60
+ }
61
+ ],
62
+ }
63
+
64
+ # Checks failed — increment retry count
65
+ retry_count += 1
66
+ error_msg = (
67
+ f"Quality gate failed (exit code {result.returncode}). Retry {retry_count}.\nOutput:\n{stored_output[:2000]}"
68
+ )
69
+
70
+ return {
71
+ "step": "verification",
72
+ "error": error_msg,
73
+ "retry_count": retry_count,
74
+ "verification_output": stored_output,
75
+ "events": [
76
+ {
77
+ "event": "verification_failed",
78
+ "timestamp": utc_now(),
79
+ "signals": {
80
+ "exit_code": result.returncode,
81
+ "retry_count": retry_count,
82
+ },
83
+ }
84
+ ],
85
+ }
86
+
87
+
88
+ def _normalize_retry_count(value: object) -> int:
89
+ """Return retry_count as int, coercing non-int values to 0."""
90
+ if isinstance(value, bool):
91
+ return 0
92
+ if not isinstance(value, int):
93
+ return 0
94
+ if value < 0:
95
+ return 0
96
+ return value
@@ -1,7 +1,9 @@
1
- """Stub node functions and conditional edge routing for the pilot work-on-jira-issue workflow.
1
+ """Routing helpers plus legacy compatibility stubs for the pilot workflow.
2
2
 
3
- All node functions are pure stubs that manipulate ``WorkOnIssueState`` without calling
4
- real Jira, Git, or Azure DevOps systems. Real tool integration is deferred to Sub-issue 2.
3
+ ``build_work_on_issue_graph`` wires real node implementations from
4
+ ``agentic_devtools.orchestration.nodes``. The node functions in this module are
5
+ kept only as lightweight compatibility shims for older direct imports and
6
+ legacy unit tests.
5
7
  """
6
8
 
7
9
  from datetime import datetime, timezone
@@ -27,8 +29,19 @@ def _normalize_retry_count(value: object) -> int:
27
29
  return value
28
30
 
29
31
 
32
+ def _normalize_retry_budget(value: object) -> int:
33
+ """Return retry_budget as int, falling back to :data:`MAX_RETRIES`."""
34
+ if isinstance(value, bool):
35
+ return MAX_RETRIES
36
+ if not isinstance(value, int):
37
+ return MAX_RETRIES
38
+ if value < 0:
39
+ return MAX_RETRIES
40
+ return value
41
+
42
+
30
43
  # ---------------------------------------------------------------------------
31
- # Node functions
44
+ # Legacy compatibility node shims
32
45
  # ---------------------------------------------------------------------------
33
46
 
34
47
 
@@ -199,10 +212,17 @@ def completion_node(state: WorkOnIssueState) -> dict:
199
212
 
200
213
 
201
214
  def error_handler_node(state: WorkOnIssueState) -> dict:
202
- """Terminal error handler: log context and mark workflow as failed."""
215
+ """Terminal error handler: log context and mark workflow as failed or blocked.
216
+
217
+ Preserves ``status="blocked"`` when the incoming state already carries that
218
+ status (e.g., from ``planning_node``), so callers can distinguish a blocked
219
+ issue from a hard failure.
220
+ """
221
+ incoming_status = state.get("status")
222
+ final_status = "blocked" if incoming_status == "blocked" else "failed"
203
223
  return {
204
224
  "step": "error_handler",
205
- "status": "failed",
225
+ "status": final_status,
206
226
  "error": state.get("error"),
207
227
  "events": [
208
228
  {
@@ -225,19 +245,15 @@ def error_handler_node(state: WorkOnIssueState) -> dict:
225
245
  def route_after_initiate(state: WorkOnIssueState) -> str:
226
246
  """Route based on initiate node outcome signals.
227
247
 
228
- Priority: error → needs_setup → issue_retrieved → legacy fallback.
248
+ Priority: error → setup. ``initiate_node`` only performs pre-flight
249
+ validation; it does not fetch the issue. Setup must run before retrieval so
250
+ branch validation/creation is always applied, and can no-op when the branch
251
+ already targets the issue. ``issue_retrieved`` is deliberately NOT consulted
252
+ here — it is owned by ``retrieve_node`` and read by ``route_after_retrieve``.
229
253
  """
230
254
  if state.get("error"):
231
255
  return "error_handler"
232
- if state.get("needs_setup"):
233
- return "setup"
234
- if state.get("issue_retrieved"):
235
- return "planning"
236
- # Legacy checkpoint fallback: pre-refactor states lack signal fields.
237
- # The previous default was "planning" (no error meant success).
238
- if "issue_retrieved" not in state and "needs_setup" not in state:
239
- return "planning"
240
- return "error_handler"
256
+ return "setup"
241
257
 
242
258
 
243
259
  def route_after_plan(state: WorkOnIssueState) -> str:
@@ -259,12 +275,26 @@ def route_after_plan(state: WorkOnIssueState) -> str:
259
275
 
260
276
 
261
277
  def route_after_setup(state: WorkOnIssueState) -> str:
262
- """Route to ``planning`` when setup completes successfully."""
278
+ """Route to ``retrieve`` when setup completes successfully."""
263
279
  if state.get("error"):
264
280
  return "error_handler"
265
281
  if state.get("setup_complete"):
266
- return "planning"
282
+ return "retrieve"
267
283
  if "setup_complete" not in state:
284
+ return "retrieve"
285
+ return "error_handler"
286
+
287
+
288
+ def route_after_retrieve(state: WorkOnIssueState) -> str:
289
+ """Route to ``planning`` when issue retrieval succeeds.
290
+
291
+ Checks the ``issue_retrieved`` signal set by the retrieve node.
292
+ """
293
+ if state.get("error"):
294
+ return "error_handler"
295
+ if state.get("issue_retrieved"):
296
+ return "planning"
297
+ if "issue_retrieved" not in state:
268
298
  return "planning"
269
299
  return "error_handler"
270
300
 
@@ -315,13 +345,15 @@ def route_after_implementation_review(state: WorkOnIssueState) -> str:
315
345
  def route_after_verify(state: WorkOnIssueState) -> str:
316
346
  """Route back to ``implementation`` on retryable error, otherwise ``commit``.
317
347
 
318
- Caps retry loops at :data:`MAX_RETRIES` to prevent infinite loops.
348
+ Caps retry loops at the policy-provided ``retry_budget`` when present,
349
+ otherwise falls back to :data:`MAX_RETRIES` for checkpoint compatibility.
319
350
  Routes to ``error_handler`` when retries are exhausted.
320
351
  """
321
352
  retry_count = _normalize_retry_count(state.get("retry_count", 0))
322
- if state.get("error") and retry_count < MAX_RETRIES:
353
+ retry_budget = _normalize_retry_budget(state.get("retry_budget", MAX_RETRIES))
354
+ if state.get("error") and retry_count < retry_budget:
323
355
  return "implementation"
324
- if state.get("error") and retry_count >= MAX_RETRIES:
356
+ if state.get("error") and retry_count >= retry_budget:
325
357
  return "error_handler"
326
358
  return "commit"
327
359
 
@@ -1,11 +1,14 @@
1
- """LangGraph workflow runner for the work-on-jira-issue workflow.
1
+ """LangGraph workflow runner for the work-on-issue workflow.
2
2
 
3
3
  This module provides the entry point for running the LangGraph-based
4
- work-on-jira-issue workflow when ``--engine langchain`` is selected.
4
+ work-on-issue workflow when ``--engine langchain`` is selected.
5
+ It supports both Jira issue keys (e.g., PROJECT-1234) and GitHub issue
6
+ numbers (e.g., #42).
5
7
  """
6
8
 
7
9
  import sys
8
10
  import uuid
11
+ from pathlib import Path
9
12
  from typing import Any
10
13
 
11
14
 
@@ -17,14 +20,15 @@ def run_langchain_workflow(
17
20
  resume: bool = False,
18
21
  resume_data: dict | None = None,
19
22
  ) -> None:
20
- """Run the LangGraph-based work-on-jira-issue workflow.
23
+ """Run the LangGraph-based work-on-issue workflow.
21
24
 
22
25
  This is invoked when ``--engine langchain`` (or ``--use-langchain``) is
23
26
  provided on the CLI. It builds and invokes the compiled StateGraph with
24
- real tool integrations.
27
+ real tool integrations. Supports both Jira issue keys (e.g., PROJECT-1234)
28
+ and GitHub issue numbers (e.g., #42).
25
29
 
26
30
  Args:
27
- issue_key: Jira issue key (e.g., PROJECT-1234).
31
+ issue_key: Jira issue key (e.g., PROJECT-1234) or GitHub issue number.
28
32
  interactive: Whether to start the Copilot session interactively.
29
33
  model: Copilot model to use.
30
34
  resume: Whether to resume from an existing checkpoint.
@@ -75,6 +79,9 @@ def run_langchain_workflow(
75
79
  # it even when get_checkpointer() raises before assignment.
76
80
  checkpointer = None
77
81
  try:
82
+ # Initialize infrastructure components for autonomous execution.
83
+ policy_state = _initialize_infrastructure(state_dir)
84
+
78
85
  # Initialize checkpointer after lock acquisition so the entire execution
79
86
  # (including checkpoint storage) is single-writer per thread scope.
80
87
  checkpointer = get_checkpointer()
@@ -154,7 +161,10 @@ def run_langchain_workflow(
154
161
  "model": model,
155
162
  },
156
163
  "affected_paths": [],
164
+ "token_usage_prompt": 0,
165
+ "token_usage_completion": 0,
157
166
  }
167
+ initial_state.update(policy_state)
158
168
 
159
169
  print(f"[langchain] Starting workflow for {issue_key}...")
160
170
  try:
@@ -174,6 +184,12 @@ def run_langchain_workflow(
174
184
  # True completion.
175
185
  final_step = result.get("step", "unknown")
176
186
  final_status = result.get("status", "unknown")
187
+ if final_status in {"failed", "blocked"}:
188
+ print(
189
+ f"ERROR: Workflow terminated unsuccessfully: step={final_step}, status={final_status}",
190
+ file=sys.stderr,
191
+ )
192
+ sys.exit(1)
177
193
  print(f"[langchain] Workflow completed: step={final_step}, status={final_status}")
178
194
  finally:
179
195
  lock.release()
@@ -183,17 +199,54 @@ def run_langchain_workflow(
183
199
  checkpointer.conn.close()
184
200
 
185
201
 
202
+ def _initialize_infrastructure(state_dir: Path | str) -> dict[str, int]:
203
+ """Initialize infrastructure components for autonomous workflow execution.
204
+
205
+ Loads policy values used by the workflow (token budget and retry budget)
206
+ and logs them for observability. Returns state defaults that should be
207
+ seeded into fresh workflow executions. If policy loading fails, emits a
208
+ warning and continues with runtime defaults.
209
+
210
+ Args:
211
+ state_dir: Path to the workflow state directory (str or Path).
212
+
213
+ Returns:
214
+ A mapping of policy-backed state defaults for fresh executions.
215
+ """
216
+ import sys
217
+
218
+ # Initialize PolicyLoader for budget enforcement
219
+ try:
220
+ from agentic_devtools.orchestration.policies.loader import PolicyLoader
221
+
222
+ loader = PolicyLoader()
223
+ policy = loader.load()
224
+ max_tokens = policy.shared.max_tokens
225
+ retry_budget = policy.work_on_issue.retry_budget
226
+ print(
227
+ f"[langchain] Policy loaded: state_dir={state_dir}, max_tokens={max_tokens}, retry_budget={retry_budget}",
228
+ file=sys.stderr,
229
+ )
230
+ return {"retry_budget": retry_budget}
231
+ except Exception as exc:
232
+ print(
233
+ f"[langchain] WARNING: Policy loading failed for state_dir={state_dir}, using defaults: {exc}",
234
+ file=sys.stderr,
235
+ )
236
+ return {}
237
+
238
+
186
239
  def _is_workflow_paused(result: object) -> bool:
187
- """Return True if the workflow is paused (not completed).
240
+ """Return True if the workflow is paused and awaiting human input.
188
241
 
189
242
  When a LangGraph workflow with a checkpointer pauses at a human-in-the-loop
190
243
  gate node, ``invoke()`` returns the current state dict instead of raising
191
- ``GraphInterrupt``. This helper detects that situation by checking whether
192
- the returned status is anything other than ``"completed"``.
244
+ ``GraphInterrupt``. This helper treats known terminal statuses as
245
+ non-paused and returns ``True`` only for active/in-progress states.
193
246
  """
194
247
  if not isinstance(result, dict):
195
248
  return True
196
- return result.get("status") != "completed"
249
+ return result.get("status") not in {"completed", "failed", "blocked"}
197
250
 
198
251
 
199
252
  def _print_pause_message(issue_key: str) -> None: